@scalar/oas-utils 0.10.7 → 0.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/entities/cookie/cookie.js +11 -15
  3. package/dist/entities/cookie/index.js +1 -5
  4. package/dist/entities/environment/environment.js +7 -11
  5. package/dist/entities/environment/index.js +1 -5
  6. package/dist/entities/hotkeys/hotkeys.js +116 -111
  7. package/dist/entities/hotkeys/index.js +1 -6
  8. package/dist/entities/shared/index.js +1 -7
  9. package/dist/entities/shared/utility.js +9 -9
  10. package/dist/entities/spec/collection.js +91 -89
  11. package/dist/entities/spec/index.js +10 -59
  12. package/dist/entities/spec/operation.js +6 -6
  13. package/dist/entities/spec/parameters.js +38 -38
  14. package/dist/entities/spec/request-examples.js +421 -331
  15. package/dist/entities/spec/requests.js +102 -84
  16. package/dist/entities/spec/server.js +61 -46
  17. package/dist/entities/spec/spec-objects.js +121 -76
  18. package/dist/entities/spec/x-scalar-environments.js +18 -20
  19. package/dist/entities/spec/x-scalar-secrets.js +6 -8
  20. package/dist/entities/workspace/index.js +1 -7
  21. package/dist/entities/workspace/workspace.js +47 -46
  22. package/dist/helpers/client-plugins.js +13 -13
  23. package/dist/helpers/fetch-document.js +30 -25
  24. package/dist/helpers/fetch-with-proxy-fallback.js +26 -21
  25. package/dist/helpers/index.d.ts +1 -2
  26. package/dist/helpers/index.d.ts.map +1 -1
  27. package/dist/helpers/index.js +80 -119
  28. package/dist/helpers/normalize-mime-type-object.js +19 -18
  29. package/dist/helpers/normalize-mime-type.js +11 -9
  30. package/dist/helpers/operation-stability.js +25 -20
  31. package/dist/helpers/parse.d.ts +0 -4
  32. package/dist/helpers/parse.d.ts.map +1 -1
  33. package/dist/helpers/parse.js +77 -77
  34. package/dist/helpers/schema-model.js +13 -16
  35. package/dist/helpers/security/get-schemes.js +7 -8
  36. package/dist/helpers/security/has-token.js +18 -19
  37. package/dist/helpers/security/index.js +2 -7
  38. package/dist/helpers/servers.js +128 -79
  39. package/dist/helpers/should-ignore-entity.js +4 -5
  40. package/dist/migrations/data-version.js +15 -7
  41. package/dist/migrations/generate-types.js +34 -37
  42. package/dist/migrations/index.js +4 -18
  43. package/dist/migrations/local-storage.js +31 -29
  44. package/dist/migrations/migrate-to-indexdb.js +706 -529
  45. package/dist/migrations/migrator.js +58 -54
  46. package/dist/migrations/semver.js +24 -24
  47. package/dist/migrations/v-0.0.0/types.generated.js +1 -1
  48. package/dist/migrations/v-2.1.0/migration.js +272 -258
  49. package/dist/migrations/v-2.1.0/types.generated.js +1 -1
  50. package/dist/migrations/v-2.2.0/migration.js +99 -96
  51. package/dist/migrations/v-2.2.0/types.generated.js +1 -1
  52. package/dist/migrations/v-2.3.0/migration.js +45 -48
  53. package/dist/migrations/v-2.3.0/types.generated.js +1 -1
  54. package/dist/migrations/v-2.4.0/migration.js +25 -25
  55. package/dist/migrations/v-2.4.0/types.generated.js +1 -1
  56. package/dist/migrations/v-2.5.0/migration.js +122 -139
  57. package/dist/migrations/v-2.5.0/types.generated.js +1 -1
  58. package/dist/spec-getters/get-example-from-schema.js +489 -385
  59. package/dist/spec-getters/get-parameters-from-operation.js +39 -23
  60. package/dist/spec-getters/get-request-body-from-operation.d.ts.map +1 -1
  61. package/dist/spec-getters/get-request-body-from-operation.js +168 -126
  62. package/dist/spec-getters/get-server-variable-examples.js +10 -13
  63. package/dist/spec-getters/index.js +4 -11
  64. package/dist/transforms/import-spec.js +381 -291
  65. package/dist/transforms/index.js +1 -11
  66. package/package.json +15 -19
  67. package/dist/entities/cookie/cookie.js.map +0 -7
  68. package/dist/entities/cookie/index.js.map +0 -7
  69. package/dist/entities/environment/environment.js.map +0 -7
  70. package/dist/entities/environment/index.js.map +0 -7
  71. package/dist/entities/hotkeys/hotkeys.js.map +0 -7
  72. package/dist/entities/hotkeys/index.js.map +0 -7
  73. package/dist/entities/shared/index.js.map +0 -7
  74. package/dist/entities/shared/utility.js.map +0 -7
  75. package/dist/entities/spec/collection.js.map +0 -7
  76. package/dist/entities/spec/index.js.map +0 -7
  77. package/dist/entities/spec/operation.js.map +0 -7
  78. package/dist/entities/spec/parameters.js.map +0 -7
  79. package/dist/entities/spec/request-examples.js.map +0 -7
  80. package/dist/entities/spec/requests.js.map +0 -7
  81. package/dist/entities/spec/server.js.map +0 -7
  82. package/dist/entities/spec/spec-objects.js.map +0 -7
  83. package/dist/entities/spec/x-scalar-environments.js.map +0 -7
  84. package/dist/entities/spec/x-scalar-secrets.js.map +0 -7
  85. package/dist/entities/workspace/index.js.map +0 -7
  86. package/dist/entities/workspace/workspace.js.map +0 -7
  87. package/dist/helpers/client-plugins.js.map +0 -7
  88. package/dist/helpers/fetch-document.js.map +0 -7
  89. package/dist/helpers/fetch-with-proxy-fallback.js.map +0 -7
  90. package/dist/helpers/index.js.map +0 -7
  91. package/dist/helpers/normalize-mime-type-object.js.map +0 -7
  92. package/dist/helpers/normalize-mime-type.js.map +0 -7
  93. package/dist/helpers/operation-stability.js.map +0 -7
  94. package/dist/helpers/parse.js.map +0 -7
  95. package/dist/helpers/pretty-print-json.d.ts +0 -9
  96. package/dist/helpers/pretty-print-json.d.ts.map +0 -1
  97. package/dist/helpers/pretty-print-json.js +0 -38
  98. package/dist/helpers/pretty-print-json.js.map +0 -7
  99. package/dist/helpers/schema-model.js.map +0 -7
  100. package/dist/helpers/security/get-schemes.js.map +0 -7
  101. package/dist/helpers/security/has-token.js.map +0 -7
  102. package/dist/helpers/security/index.js.map +0 -7
  103. package/dist/helpers/servers.js.map +0 -7
  104. package/dist/helpers/should-ignore-entity.js.map +0 -7
  105. package/dist/migrations/data-version.js.map +0 -7
  106. package/dist/migrations/generate-types.js.map +0 -7
  107. package/dist/migrations/index.js.map +0 -7
  108. package/dist/migrations/local-storage.js.map +0 -7
  109. package/dist/migrations/migrate-to-indexdb.js.map +0 -7
  110. package/dist/migrations/migrator.js.map +0 -7
  111. package/dist/migrations/semver.js.map +0 -7
  112. package/dist/migrations/v-0.0.0/types.generated.js.map +0 -7
  113. package/dist/migrations/v-2.1.0/migration.js.map +0 -7
  114. package/dist/migrations/v-2.1.0/types.generated.js.map +0 -7
  115. package/dist/migrations/v-2.2.0/migration.js.map +0 -7
  116. package/dist/migrations/v-2.2.0/types.generated.js.map +0 -7
  117. package/dist/migrations/v-2.3.0/migration.js.map +0 -7
  118. package/dist/migrations/v-2.3.0/types.generated.js.map +0 -7
  119. package/dist/migrations/v-2.4.0/migration.js.map +0 -7
  120. package/dist/migrations/v-2.4.0/types.generated.js.map +0 -7
  121. package/dist/migrations/v-2.5.0/migration.js.map +0 -7
  122. package/dist/migrations/v-2.5.0/types.generated.js.map +0 -7
  123. package/dist/spec-getters/get-example-from-schema.js.map +0 -7
  124. package/dist/spec-getters/get-parameters-from-operation.js.map +0 -7
  125. package/dist/spec-getters/get-request-body-from-operation.js.map +0 -7
  126. package/dist/spec-getters/get-server-variable-examples.js.map +0 -7
  127. package/dist/spec-getters/index.js.map +0 -7
  128. package/dist/transforms/import-spec.js.map +0 -7
  129. package/dist/transforms/index.js.map +0 -7
@@ -1,607 +1,784 @@
1
- import { CONTENT_TYPES } from "@scalar/helpers/consts/content-types";
2
- import { createLimiter } from "@scalar/helpers/general/create-limiter";
3
- import { extractConfigSecrets, removeSecretFields } from "@scalar/helpers/general/extract-config-secrets";
4
- import { objectEntries } from "@scalar/helpers/object/object-entries";
5
- import { toJsonCompatible } from "@scalar/helpers/object/to-json-compatible";
6
- import { extractServerFromPath } from "@scalar/helpers/url/extract-server-from-path";
7
- import { presets } from "@scalar/themes";
8
- import { createWorkspaceStore } from "@scalar/workspace-store/client";
9
- import { AuthSchema } from "@scalar/workspace-store/entities/auth";
10
- import { createWorkspaceStorePersistence } from "@scalar/workspace-store/persistence";
11
- import {
12
- xScalarEnvironmentSchema
13
- } from "@scalar/workspace-store/schemas/extensions/document/x-scalar-environments";
14
- import { xScalarCookieSchema } from "@scalar/workspace-store/schemas/extensions/general/x-scalar-cookies";
15
- import { coerceValue } from "@scalar/workspace-store/schemas/typebox-coerce";
16
- import { ColorModeSchema } from "@scalar/workspace-store/schemas/workspace";
17
- import GithubSlugger from "github-slugger";
18
- import { migrator } from "../migrations/migrator.js";
19
- const DRAFTS_DOCUMENT_NAME = "drafts";
1
+ import { CONTENT_TYPES } from '@scalar/helpers/consts/content-types';
2
+ import { createLimiter } from '@scalar/helpers/general/create-limiter';
3
+ import { extractConfigSecrets, removeSecretFields } from '@scalar/helpers/general/extract-config-secrets';
4
+ import { objectEntries } from '@scalar/helpers/object/object-entries';
5
+ import { toJsonCompatible } from '@scalar/helpers/object/to-json-compatible';
6
+ import { extractServerFromPath } from '@scalar/helpers/url/extract-server-from-path';
7
+ import { presets } from '@scalar/themes';
8
+ import { createWorkspaceStore } from '@scalar/workspace-store/client';
9
+ import { AuthSchema } from '@scalar/workspace-store/entities/auth';
10
+ import { createWorkspaceStorePersistence } from '@scalar/workspace-store/persistence';
11
+ import { xScalarEnvironmentSchema, } from '@scalar/workspace-store/schemas/extensions/document/x-scalar-environments';
12
+ import { xScalarCookieSchema } from '@scalar/workspace-store/schemas/extensions/general/x-scalar-cookies';
13
+ import { coerceValue } from '@scalar/workspace-store/schemas/typebox-coerce';
14
+ import { ColorModeSchema } from '@scalar/workspace-store/schemas/workspace';
15
+ import GithubSlugger from 'github-slugger';
16
+ import { migrator } from '../migrations/migrator.js';
17
+ const DRAFTS_DOCUMENT_NAME = 'drafts';
20
18
  const MAX_CONCURRENT_DB_WRITES = 100;
21
19
  const MAX_CONCURRENT_DATA_TRANSFORMATIONS = 5;
22
- const migrateLocalStorageToIndexDb = async () => {
23
- const { close, workspace: workspacePersistence } = await createWorkspaceStorePersistence();
24
- try {
25
- const shouldMigrate = await shouldMigrateToIndexDb(workspacePersistence);
26
- if (!shouldMigrate) {
27
- console.info("\u2139\uFE0F No migration needed - IndexedDB already has workspaces or no legacy data exists");
28
- return;
20
+ /**
21
+ * Migrates localStorage data to IndexedDB workspace structure.
22
+ *
23
+ * Called early in app initialization (app-state.ts) before workspace data loads.
24
+ * Idempotent and non-destructive - runs when legacy data exists but IndexedDB is empty.
25
+ *
26
+ * Flow:
27
+ * 1. Check if migration needed (has legacy data + IndexedDB is empty)
28
+ * 2. Run existing migrations to get latest data structure
29
+ * 3. Transform to new workspace format
30
+ * 4. Save to IndexedDB
31
+ *
32
+ * Old data is preserved for rollback. Typically completes in < 1 second.
33
+ */
34
+ export const migrateLocalStorageToIndexDb = async () => {
35
+ const { close, workspace: workspacePersistence } = await createWorkspaceStorePersistence();
36
+ try {
37
+ const shouldMigrate = await shouldMigrateToIndexDb(workspacePersistence);
38
+ if (!shouldMigrate) {
39
+ console.info('ℹ️ No migration needed - IndexedDB already has workspaces or no legacy data exists');
40
+ return;
41
+ }
42
+ console.info('🚀 Starting migration from localStorage to IndexedDB...');
43
+ // Step 1: Run existing migrations to get latest data structure
44
+ const legacyData = migrator();
45
+ console.info(`📦 Found legacy data: ${legacyData.arrays.workspaces.length} workspace(s), ${legacyData.arrays.collections.length} collection(s)`);
46
+ // Step 2: Transform to new workspace structure
47
+ const workspaces = await transformLegacyDataToWorkspace(legacyData);
48
+ const limit = createLimiter(MAX_CONCURRENT_DB_WRITES);
49
+ // Step 3: Save to IndexedDB
50
+ await Promise.all(workspaces.map((workspace) => limit(() => workspacePersistence.setItem({ namespace: 'local', slug: workspace.slug }, {
51
+ name: workspace.name,
52
+ workspace: workspace.workspace,
53
+ teamUid: 'local',
54
+ }))));
55
+ console.info(`✅ Successfully migrated ${workspaces.length} workspace(s) to IndexedDB`);
56
+ }
57
+ catch (error) {
58
+ console.error('❌ Migration failed:', error);
59
+ }
60
+ finally {
61
+ close();
29
62
  }
30
- console.info("\u{1F680} Starting migration from localStorage to IndexedDB...");
31
- const legacyData = migrator();
32
- console.info(
33
- `\u{1F4E6} Found legacy data: ${legacyData.arrays.workspaces.length} workspace(s), ${legacyData.arrays.collections.length} collection(s)`
34
- );
35
- const workspaces = await transformLegacyDataToWorkspace(legacyData);
36
- const limit = createLimiter(MAX_CONCURRENT_DB_WRITES);
37
- await Promise.all(
38
- workspaces.map(
39
- (workspace) => limit(
40
- () => workspacePersistence.setItem(
41
- { namespace: "local", slug: workspace.slug },
42
- {
43
- name: workspace.name,
44
- workspace: workspace.workspace,
45
- teamUid: "local"
46
- }
47
- )
48
- )
49
- )
50
- );
51
- console.info(`\u2705 Successfully migrated ${workspaces.length} workspace(s) to IndexedDB`);
52
- } catch (error) {
53
- console.error("\u274C Migration failed:", error);
54
- } finally {
55
- close();
56
- }
57
63
  };
58
- const shouldMigrateToIndexDb = async (workspacePersistence) => {
59
- const hasLegacyData = localStorage.getItem("workspace") !== null || localStorage.getItem("collection") !== null || localStorage.getItem("request") !== null;
60
- if (!hasLegacyData) {
61
- return false;
62
- }
63
- const existingWorkspaces = await workspacePersistence.getAll();
64
- const hasIndexDbData = existingWorkspaces.length > 0;
65
- return !hasIndexDbData;
64
+ /**
65
+ * Checks if migration is needed by verifying IndexedDB state and presence of legacy data.
66
+ *
67
+ * Migration is needed when:
68
+ * 1. Legacy data exists in localStorage (workspace, collection, or request keys)
69
+ * 2. AND IndexedDB has no workspaces yet
70
+ *
71
+ * This approach is more reliable than using a flag because:
72
+ * - If IndexedDB is cleared, migration will run again automatically
73
+ * - No risk of flag getting out of sync with actual data state
74
+ * - Handles edge cases like partial migrations or database corruption
75
+ */
76
+ export const shouldMigrateToIndexDb = async (workspacePersistence) => {
77
+ // Check if there is any old data in localStorage
78
+ const hasLegacyData = localStorage.getItem('workspace') !== null ||
79
+ localStorage.getItem('collection') !== null ||
80
+ localStorage.getItem('request') !== null;
81
+ if (!hasLegacyData) {
82
+ return false;
83
+ }
84
+ // Check if IndexedDB already has workspaces
85
+ const existingWorkspaces = await workspacePersistence.getAll();
86
+ const hasIndexDbData = existingWorkspaces.length > 0;
87
+ // Only migrate if we have legacy data but no IndexedDB data
88
+ return !hasIndexDbData;
66
89
  };
67
- const transformLegacyDataToWorkspace = async (legacyData) => {
68
- const limitWorkspaceTransform = createLimiter(MAX_CONCURRENT_DATA_TRANSFORMATIONS);
69
- return await Promise.all(
70
- legacyData.arrays.workspaces.map(
71
- (workspace) => limitWorkspaceTransform(async () => {
90
+ /**
91
+ * Transforms legacy localStorage data into IndexedDB workspace structure.
92
+ *
93
+ * Transformations:
94
+ * - Collections → Documents (collections were OpenAPI specs)
95
+ * - Environments → x-scalar-environments in meta
96
+ * - Cookies → x-scalar-cookies in meta
97
+ * - Workspace properties → meta extensions (activeEnvironmentId, proxyUrl, themeId)
98
+ *
99
+ * Creates a default workspace if none exist. Falls back to collection uid if info.title is missing.
100
+ */
101
+ export const transformLegacyDataToWorkspace = async (legacyData) => {
102
+ const limitWorkspaceTransform = createLimiter(MAX_CONCURRENT_DATA_TRANSFORMATIONS);
103
+ return await Promise.all(legacyData.arrays.workspaces.map((workspace) => limitWorkspaceTransform(async () => {
104
+ /** Grab auth from the collections */
72
105
  const workspaceAuth = {};
106
+ /** Create a slugger instance per workspace to handle duplicate document names */
73
107
  const documentSlugger = new GithubSlugger();
74
- const documents = workspace.collections.flatMap(
75
- (uid) => {
108
+ /** Each collection becomes a document in the new system and grab the auth as well */
109
+ const documents = workspace.collections.flatMap((uid) => {
76
110
  const collection = legacyData.records.collections[uid];
77
111
  if (!collection) {
78
- return [];
112
+ return [];
79
113
  }
80
- const documentName = collection.info?.title || "api";
114
+ const documentName = collection.info?.title || 'api';
81
115
  const { document, auth } = transformCollectionToDocument(documentName, collection, legacyData.records);
82
- const normalizedName = documentName === "Drafts" ? "drafts" : documentName;
116
+ // Normalize document name to match the store (lowercase "Drafts" "drafts")
117
+ const normalizedName = documentName === 'Drafts' ? 'drafts' : documentName;
118
+ // Use GitHubSlugger to ensure unique document names
83
119
  const uniqueName = documentSlugger.slug(normalizedName, false);
84
120
  workspaceAuth[uniqueName] = auth;
85
121
  return { name: uniqueName, document };
86
- }
87
- );
122
+ });
88
123
  const meta = {};
89
124
  const extensions = {};
125
+ // Add environment
90
126
  const environmentEntries = Object.entries(workspace.environments);
91
127
  if (environmentEntries.length > 0) {
92
- extensions["x-scalar-environments"] = {
93
- default: coerceValue(xScalarEnvironmentSchema, {
94
- variables: environmentEntries.map(([name, value]) => ({
95
- name,
96
- value
97
- }))
98
- })
99
- };
128
+ extensions['x-scalar-environments'] = {
129
+ default: coerceValue(xScalarEnvironmentSchema, {
130
+ variables: environmentEntries.map(([name, value]) => ({
131
+ name,
132
+ value,
133
+ })),
134
+ }),
135
+ };
100
136
  }
137
+ // Add cookies to meta
101
138
  if (workspace.cookies.length > 0) {
102
- extensions["x-scalar-cookies"] = workspace.cookies.flatMap((uid) => {
103
- const cookie = legacyData.records.cookies[uid];
104
- return cookie ? coerceValue(xScalarCookieSchema, cookie) : [];
105
- });
139
+ extensions['x-scalar-cookies'] = workspace.cookies.flatMap((uid) => {
140
+ const cookie = legacyData.records.cookies[uid];
141
+ return cookie ? coerceValue(xScalarCookieSchema, cookie) : [];
142
+ });
106
143
  }
144
+ // Add proxy URL if present
107
145
  if (workspace.proxyUrl) {
108
- meta["x-scalar-active-proxy"] = workspace.proxyUrl;
146
+ meta['x-scalar-active-proxy'] = workspace.proxyUrl;
109
147
  }
148
+ // Add theme if present
110
149
  if (workspace.themeId) {
111
- meta["x-scalar-theme"] = transformThemeIdToSlug(workspace.themeId);
150
+ // We use theme slugs on the new system so we need to transform the id to the slug
151
+ meta['x-scalar-theme'] = transformThemeIdToSlug(workspace.themeId);
112
152
  }
113
- if (localStorage.getItem("colorMode")) {
114
- meta["x-scalar-color-mode"] = coerceValue(ColorModeSchema, localStorage.getItem("colorMode"));
153
+ // Set color mode
154
+ if (localStorage.getItem('colorMode')) {
155
+ meta['x-scalar-color-mode'] = coerceValue(ColorModeSchema, localStorage.getItem('colorMode'));
115
156
  }
116
157
  const store = createWorkspaceStore({
117
- meta
158
+ meta,
118
159
  });
119
160
  const limitDocumentAdd = createLimiter(MAX_CONCURRENT_DATA_TRANSFORMATIONS);
120
- await Promise.all(
121
- documents.map(
122
- ({ name, document }) => limitDocumentAdd(async () => {
123
- await store.addDocument({
161
+ await Promise.all(documents.map(({ name, document }) => limitDocumentAdd(async () => {
162
+ await store.addDocument({
124
163
  name,
125
- document
126
- });
127
- })
128
- )
129
- );
164
+ document,
165
+ });
166
+ // Note: we are breaking the relationship between the document and the originial source url
167
+ })));
168
+ // Try to always set the drafts / route
130
169
  if (!(DRAFTS_DOCUMENT_NAME in store.workspace.documents)) {
131
- await store.addDocument({
132
- name: DRAFTS_DOCUMENT_NAME,
133
- document: {
134
- openapi: "3.1.0",
135
- info: {
136
- title: "Drafts",
137
- version: "1.0.0"
138
- },
139
- paths: {
140
- "/": {
141
- get: {}
142
- }
143
- },
144
- "x-scalar-icon": "interface-edit-tool-pencil"
145
- }
146
- });
170
+ await store.addDocument({
171
+ name: DRAFTS_DOCUMENT_NAME,
172
+ document: {
173
+ openapi: '3.1.0',
174
+ info: {
175
+ title: 'Drafts',
176
+ version: '1.0.0',
177
+ },
178
+ paths: {
179
+ '/': {
180
+ get: {},
181
+ },
182
+ },
183
+ 'x-scalar-icon': 'interface-edit-tool-pencil',
184
+ },
185
+ });
147
186
  }
148
187
  const drafts = store.workspace.documents[DRAFTS_DOCUMENT_NAME];
149
188
  if (drafts) {
150
- drafts.paths ??= {};
151
- drafts.paths["/"] ??= {};
152
- drafts.paths["/"]["get"] ??= {};
189
+ // Make sure the drafts document has a GET / route cuz that's the first route we navigate the user to
190
+ drafts.paths ??= {};
191
+ drafts.paths['/'] ??= {};
192
+ drafts.paths['/']['get'] ??= {};
153
193
  }
154
194
  store.buildSidebar(DRAFTS_DOCUMENT_NAME);
195
+ // save the document to the store so we don't see the document as dirty
155
196
  await store.saveDocument(DRAFTS_DOCUMENT_NAME);
197
+ // Load the auth into the store
156
198
  store.auth.load(workspaceAuth);
199
+ // Load the extensions into the store
157
200
  objectEntries(extensions).forEach(([key, value]) => {
158
- store.update(key, value);
201
+ store.update(key, value);
159
202
  });
160
203
  return {
161
- slug: workspace.uid.toString(),
162
- // Convert to string to convert it to a simple string type
163
- name: workspace.name || "Untitled Workspace",
164
- workspace: store.exportWorkspace()
204
+ slug: workspace.uid.toString(), // Convert to string to convert it to a simple string type
205
+ name: workspace.name || 'Untitled Workspace',
206
+ workspace: store.exportWorkspace(),
165
207
  };
166
- })
167
- )
168
- );
208
+ })));
169
209
  };
210
+ /**
211
+ * Converts a ThemeId to its corresponding theme slug.
212
+ * If the themeId is 'none', return it as is.
213
+ * Otherwise, look up the slug in the presets object.
214
+ */
170
215
  const transformThemeIdToSlug = (themeId) => {
171
- if (themeId === "none") {
172
- return themeId;
173
- }
174
- return presets[themeId]?.slug ?? "default";
216
+ if (themeId === 'none') {
217
+ return themeId;
218
+ }
219
+ return presets[themeId]?.slug ?? 'default';
175
220
  };
221
+ /**
222
+ * Converts legacy environment variables from record format to the new array format.
223
+ *
224
+ * Legacy format: { variables: { API_URL: 'https://...', API_KEY: 'secret' } }
225
+ * New format: { variables: [{ name: 'API_URL', value: 'https://...' }, { name: 'API_KEY', value: 'secret' }] }
226
+ */
176
227
  const transformLegacyEnvironments = (environments) => {
177
- const entries = Object.entries(environments || {});
178
- if (entries.length === 0) {
179
- return void 0;
180
- }
181
- return Object.fromEntries(
182
- entries.map(([envName, env]) => [
183
- envName,
184
- coerceValue(xScalarEnvironmentSchema, {
185
- color: env.color,
186
- variables: Object.entries(env.variables || {}).map(([name, value]) => ({
187
- name,
188
- value: typeof value === "string" ? value : value.default || ""
189
- }))
190
- })
191
- ])
192
- );
228
+ const entries = Object.entries(environments || {});
229
+ if (entries.length === 0) {
230
+ return undefined;
231
+ }
232
+ return Object.fromEntries(entries.map(([envName, env]) => [
233
+ envName,
234
+ coerceValue(xScalarEnvironmentSchema, {
235
+ color: env.color,
236
+ variables: Object.entries(env.variables || {}).map(([name, value]) => ({
237
+ name,
238
+ value: typeof value === 'string' ? value : value.default || '',
239
+ })),
240
+ }),
241
+ ]));
193
242
  };
243
+ /**
244
+ * Transforms legacy requests and request examples into OpenAPI paths.
245
+ *
246
+ * Each request becomes an operation in the paths object.
247
+ * Request examples are merged into parameter examples and request body examples.
248
+ *
249
+ * Also extracts servers from paths that contain full URLs (e.g., "https://api.example.com/users")
250
+ * and returns them separately for deduplication at the document level.
251
+ */
194
252
  const transformRequestsToPaths = (collection, dataRecords) => {
195
- const paths = /* @__PURE__ */ Object.create(null);
196
- const extractedServers = [];
197
- for (const requestUid of collection.requests || []) {
198
- const request = dataRecords.requests[requestUid];
199
- if (!request) {
200
- continue;
201
- }
202
- const {
203
- path,
204
- method,
205
- uid: _uid,
206
- type: _type,
207
- selectedServerUid: _selectedServerUid,
208
- examples,
209
- servers,
210
- selectedSecuritySchemeUids: _selectedSecuritySchemeUids,
211
- parameters = [],
212
- requestBody,
213
- ...rest
214
- } = request;
215
- let normalizedPath = path || "/";
216
- const extractedServerUrl = extractServerFromPath(normalizedPath);
217
- if (extractedServerUrl?.length === 2) {
218
- const [serverUrl, remainingPath] = extractedServerUrl;
219
- extractedServers.push({ url: serverUrl });
220
- normalizedPath = remainingPath;
221
- if (!normalizedPath) {
222
- normalizedPath = "/";
223
- } else if (normalizedPath.startsWith("//")) {
224
- normalizedPath = normalizedPath.slice(1);
225
- }
226
- }
227
- if (!normalizedPath.startsWith("/")) {
228
- normalizedPath = `/${normalizedPath}`;
229
- }
230
- if (!paths[normalizedPath]) {
231
- paths[normalizedPath] = {};
232
- }
233
- const partialOperation = {
234
- ...rest
235
- };
236
- const requestExamples = (examples || []).flatMap((exampleUid) => {
237
- const example = dataRecords.requestExamples[exampleUid];
238
- return example ? [example] : [];
239
- });
240
- const mergedParameters = mergeExamplesIntoParameters(parameters, requestExamples);
241
- if (mergedParameters.length > 0) {
242
- partialOperation.parameters = mergedParameters;
243
- }
244
- const mergedRequestBody = mergeExamplesIntoRequestBody(requestBody, requestExamples);
245
- if (mergedRequestBody) {
246
- partialOperation.requestBody = mergedRequestBody;
247
- }
248
- if (servers && servers.length > 0) {
249
- partialOperation.servers = servers.flatMap((serverUid) => {
250
- const server = dataRecords.servers[serverUid];
251
- if (!server) {
252
- return [];
253
+ const paths = Object.create(null);
254
+ const extractedServers = [];
255
+ for (const requestUid of collection.requests || []) {
256
+ const request = dataRecords.requests[requestUid];
257
+ if (!request) {
258
+ continue;
259
+ }
260
+ const { path, method, uid: _uid, type: _type, selectedServerUid: _selectedServerUid, examples, servers, selectedSecuritySchemeUids: _selectedSecuritySchemeUids, parameters = [], requestBody, ...rest } = request;
261
+ let normalizedPath = path || '/';
262
+ /**
263
+ * Extract server from path if it contains a full URL.
264
+ * This handles legacy data where users may have entered full URLs as paths.
265
+ */
266
+ const extractedServerUrl = extractServerFromPath(normalizedPath);
267
+ if (extractedServerUrl?.length === 2) {
268
+ const [serverUrl, remainingPath] = extractedServerUrl;
269
+ extractedServers.push({ url: serverUrl });
270
+ normalizedPath = remainingPath;
271
+ /**
272
+ * Handle edge case where the path after server is empty or just "/"
273
+ * Example: "https://api.example.com" "" "/"
274
+ */
275
+ if (!normalizedPath) {
276
+ normalizedPath = '/';
277
+ }
278
+ // Handle double slashes from malformed URLs like "https://api.example.com//users"
279
+ else if (normalizedPath.startsWith('//')) {
280
+ normalizedPath = normalizedPath.slice(1);
281
+ }
282
+ }
283
+ // Normalize relative paths to start with a leading slash. OpenAPI paths must start with "/" per the spec
284
+ if (!normalizedPath.startsWith('/')) {
285
+ normalizedPath = `/${normalizedPath}`;
286
+ }
287
+ // Initialize path object if it doesn't exist
288
+ if (!paths[normalizedPath]) {
289
+ paths[normalizedPath] = {};
290
+ }
291
+ /** Start building the OAS operation object */
292
+ const partialOperation = {
293
+ ...rest,
294
+ };
295
+ // Get request examples for this request
296
+ const requestExamples = (examples || []).flatMap((exampleUid) => {
297
+ const example = dataRecords.requestExamples[exampleUid];
298
+ return example ? [example] : [];
299
+ });
300
+ // Merge examples into parameters
301
+ const mergedParameters = mergeExamplesIntoParameters(parameters, requestExamples);
302
+ if (mergedParameters.length > 0) {
303
+ partialOperation.parameters = mergedParameters;
304
+ }
305
+ // Merge examples into request body
306
+ const mergedRequestBody = mergeExamplesIntoRequestBody(requestBody, requestExamples);
307
+ if (mergedRequestBody) {
308
+ partialOperation.requestBody = mergedRequestBody;
309
+ }
310
+ // Add server overrides if present
311
+ if (servers && servers.length > 0) {
312
+ partialOperation.servers = servers.flatMap((serverUid) => {
313
+ const server = dataRecords.servers[serverUid];
314
+ if (!server) {
315
+ return [];
316
+ }
317
+ const { uid: _, ...rest } = server;
318
+ return [rest];
319
+ });
320
+ }
321
+ const pathItem = paths[normalizedPath];
322
+ if (pathItem) {
323
+ pathItem[method] = partialOperation;
253
324
  }
254
- const { uid: _, ...rest2 } = server;
255
- return [rest2];
256
- });
257
- }
258
- const pathItem = paths[normalizedPath];
259
- if (pathItem) {
260
- pathItem[method] = partialOperation;
261
325
  }
262
- }
263
- return { paths, extractedServers };
326
+ return { paths, extractedServers };
264
327
  };
328
+ /**
329
+ * The legacy data model uses plural "headers"/"cookies" for parameter categories,
330
+ * but OpenAPI uses singular "header"/"cookie" for the `in` field. This mapping
331
+ * normalizes the legacy names to their OpenAPI equivalents.
332
+ */
265
333
  const PARAM_TYPE_TO_IN = {
266
- path: "path",
267
- query: "query",
268
- headers: "header",
269
- cookies: "cookie"
334
+ path: 'path',
335
+ query: 'query',
336
+ headers: 'header',
337
+ cookies: 'cookie',
270
338
  };
339
+ /**
340
+ * Ensures unique example names by appending #2, #3, etc. when duplicates are found.
341
+ * Does not use slugification - preserves the original name with a numeric suffix.
342
+ */
271
343
  const ensureUniqueExampleName = (baseName, usedNames) => {
272
- let uniqueName = baseName;
273
- let counter = 2;
274
- while (usedNames.has(uniqueName)) {
275
- uniqueName = `${baseName} #${counter}`;
276
- counter++;
277
- }
278
- usedNames.add(uniqueName);
279
- return uniqueName;
344
+ let uniqueName = baseName;
345
+ let counter = 2;
346
+ while (usedNames.has(uniqueName)) {
347
+ uniqueName = `${baseName} #${counter}`;
348
+ counter++;
349
+ }
350
+ usedNames.add(uniqueName);
351
+ return uniqueName;
280
352
  };
353
+ /**
354
+ * Merges request example values into OpenAPI parameter objects.
355
+ *
356
+ * In the legacy data model, parameter values live on individual RequestExample
357
+ * objects (one per "example" tab in the UI). OpenAPI instead stores examples
358
+ * directly on each Parameter object via the `examples` map.
359
+ */
281
360
  const mergeExamplesIntoParameters = (parameters, requestExamples) => {
282
- const paramEntries = /* @__PURE__ */ new Map();
283
- for (const param of parameters) {
284
- let paramObject;
285
- if (param.content && typeof param.content === "object") {
286
- paramObject = {
287
- name: param.name,
288
- in: param.in,
289
- required: param.required ?? param.in === "path",
290
- deprecated: param.deprecated ?? false,
291
- content: param.content,
292
- ...param.description && { description: param.description }
293
- };
294
- } else {
295
- paramObject = {
296
- name: param.name,
297
- in: param.in,
298
- required: param.required ?? param.in === "path",
299
- deprecated: param.deprecated ?? false,
300
- ...param.description && { description: param.description },
301
- ...param.schema ? { schema: param.schema } : {},
302
- ...param.style && { style: param.style },
303
- ...param.explode !== void 0 && { explode: param.explode },
304
- ...param.example !== void 0 && { example: param.example },
305
- ...param.examples && typeof param.examples === "object" && {
306
- examples: param.examples
307
- }
308
- };
309
- }
310
- paramEntries.set(`${param.in}:${param.name}`, {
311
- param: paramObject,
312
- examples: {}
313
- });
314
- }
315
- const paramTypes = Object.keys(PARAM_TYPE_TO_IN);
316
- const usedExampleNames = /* @__PURE__ */ new Set();
317
- for (const requestExample of requestExamples) {
318
- const baseName = requestExample.name || "Example";
319
- const exampleName = ensureUniqueExampleName(baseName, usedExampleNames);
320
- for (const paramType of paramTypes) {
321
- const inValue = PARAM_TYPE_TO_IN[paramType];
322
- const items = requestExample.parameters?.[paramType] || [];
323
- for (const item of items) {
324
- const key = `${inValue}:${item.key}`;
325
- if (!item.key) {
326
- continue;
327
- }
328
- const lowerKey = item.key.toLowerCase();
329
- if (!paramEntries.has(key) && (lowerKey !== "content-type" || !CONTENT_TYPES[item.value]) && (lowerKey !== "accept" || item.value !== "*/*")) {
330
- paramEntries.set(key, {
331
- param: {
332
- name: item.key,
333
- in: inValue ?? "query",
334
- required: inValue === "path",
335
- deprecated: false,
336
- schema: { type: "string" }
337
- },
338
- examples: {}
339
- });
361
+ /**
362
+ * We track parameters and their collected examples together in a single map
363
+ * keyed by `{in}:{name}` (e.g. "query:page") to avoid a second lookup pass.
364
+ */
365
+ const paramEntries = new Map();
366
+ // Seed with the operation's existing parameters so they are preserved even if
367
+ // no request example references them.
368
+ for (const param of parameters) {
369
+ // Build a type-safe ParameterObject by explicitly mapping properties
370
+ // The old RequestParameter type uses z.unknown() for schema/content/examples,
371
+ // but these values come from validated OpenAPI documents and are already in the correct format.
372
+ // We use type assertions (via unknown) to bridge from the old loose types to the new strict types.
373
+ // This is safe because the data has already been validated by the Zod schema.
374
+ // Build either ParameterWithSchemaObject or ParameterWithContentObject
375
+ let paramObject;
376
+ // Param with Content Type
377
+ if (param.content && typeof param.content === 'object') {
378
+ paramObject = {
379
+ name: param.name,
380
+ in: param.in,
381
+ required: param.required ?? param.in === 'path',
382
+ deprecated: param.deprecated ?? false,
383
+ content: param.content,
384
+ ...(param.description && { description: param.description }),
385
+ };
340
386
  }
341
- const param = paramEntries.get(key);
342
- if (!param) {
343
- continue;
387
+ // Param with Schema Type
388
+ else {
389
+ paramObject = {
390
+ name: param.name,
391
+ in: param.in,
392
+ required: param.required ?? param.in === 'path',
393
+ deprecated: param.deprecated ?? false,
394
+ ...(param.description && { description: param.description }),
395
+ ...(param.schema ? { schema: param.schema } : {}),
396
+ ...(param.style && { style: param.style }),
397
+ ...(param.explode !== undefined && { explode: param.explode }),
398
+ ...(param.example !== undefined && { example: param.example }),
399
+ ...(param.examples &&
400
+ typeof param.examples === 'object' && {
401
+ examples: param.examples,
402
+ }),
403
+ };
344
404
  }
345
- param.examples[exampleName] = {
346
- value: item.value,
347
- "x-disabled": !item.enabled
348
- };
349
- }
405
+ paramEntries.set(`${param.in}:${param.name}`, {
406
+ param: paramObject,
407
+ examples: {},
408
+ });
350
409
  }
351
- }
352
- return Array.from(paramEntries.values()).map(({ param, examples }) => {
353
- if (Object.keys(examples).length > 0) {
354
- ;
355
- param.examples = examples;
410
+ const paramTypes = Object.keys(PARAM_TYPE_TO_IN);
411
+ const usedExampleNames = new Set();
412
+ for (const requestExample of requestExamples) {
413
+ const baseName = requestExample.name || 'Example';
414
+ const exampleName = ensureUniqueExampleName(baseName, usedExampleNames);
415
+ for (const paramType of paramTypes) {
416
+ const inValue = PARAM_TYPE_TO_IN[paramType];
417
+ const items = requestExample.parameters?.[paramType] || [];
418
+ for (const item of items) {
419
+ const key = `${inValue}:${item.key}`;
420
+ // Lets not save any params without a key
421
+ if (!item.key) {
422
+ continue;
423
+ }
424
+ const lowerKey = item.key.toLowerCase();
425
+ /**
426
+ * Lazily create a parameter stub when one does not already exist
427
+ * Path parameters are always required per the OpenAPI spec
428
+ *
429
+ * We do not add Accept: *\/*
430
+ * We do not add any Content-Type headers that are auto added in the client
431
+ */
432
+ if (!paramEntries.has(key) &&
433
+ (lowerKey !== 'content-type' || !CONTENT_TYPES[item.value]) &&
434
+ (lowerKey !== 'accept' || item.value !== '*/*')) {
435
+ paramEntries.set(key, {
436
+ param: {
437
+ name: item.key,
438
+ in: inValue ?? 'query',
439
+ required: inValue === 'path',
440
+ deprecated: false,
441
+ schema: { type: 'string' },
442
+ },
443
+ examples: {},
444
+ });
445
+ }
446
+ // We have skipped the content-type or accept headers above
447
+ const param = paramEntries.get(key);
448
+ if (!param) {
449
+ continue;
450
+ }
451
+ param.examples[exampleName] = {
452
+ value: item.value,
453
+ 'x-disabled': !item.enabled,
454
+ };
455
+ }
456
+ }
356
457
  }
357
- return param;
358
- });
458
+ // Build the final parameter list, only attaching `examples` when there are any
459
+ return Array.from(paramEntries.values()).map(({ param, examples }) => {
460
+ if (Object.keys(examples).length > 0) {
461
+ ;
462
+ param.examples = examples;
463
+ }
464
+ return param;
465
+ });
359
466
  };
467
+ /** Maps legacy raw body encoding names (e.g. "json", "xml") to their corresponding MIME content types */
360
468
  const RAW_ENCODING_TO_CONTENT_TYPE = {
361
- json: "application/json",
362
- xml: "application/xml",
363
- yaml: "application/yaml",
364
- edn: "application/edn",
365
- text: "text/plain",
366
- html: "text/html",
367
- javascript: "application/javascript"
469
+ json: 'application/json',
470
+ xml: 'application/xml',
471
+ yaml: 'application/yaml',
472
+ edn: 'application/edn',
473
+ text: 'text/plain',
474
+ html: 'text/html',
475
+ javascript: 'application/javascript',
368
476
  };
477
+ /**
478
+ * Extracts the content type and example value from a single request example body.
479
+ *
480
+ * The legacy data model stored body content in one of three shapes:
481
+ * - `raw` — text-based body with an encoding hint (json, xml, etc.)
482
+ * - `formData` — key/value pairs with either multipart or URL-encoded encoding
483
+ * - `binary` — file upload with no inline content
484
+ */
369
485
  const extractBodyExample = (body) => {
370
- if (!body?.activeBody) {
371
- return void 0;
372
- }
373
- if (body.activeBody === "raw" && body.raw) {
374
- return {
375
- contentType: RAW_ENCODING_TO_CONTENT_TYPE[body.raw.encoding] || "text/plain",
376
- value: body.raw.value
377
- };
378
- }
379
- if (body.activeBody === "formData" && body.formData) {
380
- return {
381
- contentType: body.formData.encoding === "form-data" ? "multipart/form-data" : "application/x-www-form-urlencoded",
382
- value: body.formData.value.flatMap(
383
- (param) => param.key ? {
384
- name: param.key,
385
- value: param.value,
386
- isDisabled: !param.enabled
387
- } : []
388
- )
389
- };
390
- }
391
- if (body.activeBody === "binary") {
392
- return { contentType: "binary", value: {} };
393
- }
394
- return void 0;
395
- };
396
- const mergeExamplesIntoRequestBody = (requestBody, requestExamples) => {
397
- const groupedByContentType = /* @__PURE__ */ new Map();
398
- const selectedContentTypes = {};
399
- const usedExampleNames = /* @__PURE__ */ new Set();
400
- for (const example of requestExamples) {
401
- const extracted = extractBodyExample(example.body);
402
- if (!extracted) {
403
- continue;
486
+ if (!body?.activeBody) {
487
+ return undefined;
404
488
  }
405
- const baseName = example.name || "Example";
406
- const name = ensureUniqueExampleName(baseName, usedExampleNames);
407
- const group = groupedByContentType.get(extracted.contentType);
408
- if (group) {
409
- group[name] = { value: extracted.value };
410
- } else {
411
- groupedByContentType.set(extracted.contentType, { [name]: { value: extracted.value } });
489
+ // Raw text body — resolve the short encoding name to a full MIME type
490
+ if (body.activeBody === 'raw' && body.raw) {
491
+ return {
492
+ contentType: RAW_ENCODING_TO_CONTENT_TYPE[body.raw.encoding] || 'text/plain',
493
+ value: body.raw.value,
494
+ };
412
495
  }
413
- selectedContentTypes[name] = extracted.contentType;
414
- }
415
- if (groupedByContentType.size === 0) {
416
- return requestBody;
417
- }
418
- const result = requestBody ?? {};
419
- result.content ??= {};
420
- for (const [contentType, examples] of groupedByContentType) {
421
- result.content[contentType] ??= {};
422
- result.content[contentType].examples = examples;
423
- }
424
- if (Object.keys(selectedContentTypes).length > 0) {
425
- result["x-scalar-selected-content-type"] = selectedContentTypes;
426
- }
427
- return result;
428
- };
429
- const transformLegacyTags = (collection, dataRecords) => {
430
- const tags = [];
431
- const tagGroups = [];
432
- const topLevelTagUids = new Set(collection.children.filter((uid) => dataRecords.tags[uid] !== void 0));
433
- const parentTagUids = new Set(
434
- collection.tags.filter((uid) => {
435
- const tag = dataRecords.tags[uid];
436
- return tag?.children && tag.children.length > 0;
437
- })
438
- );
439
- for (const tagUid of collection.tags) {
440
- const tag = dataRecords.tags[tagUid];
441
- if (!tag) {
442
- continue;
496
+ // Form data — distinguish between multipart (file uploads) and URL-encoded
497
+ if (body.activeBody === 'formData' && body.formData) {
498
+ return {
499
+ contentType: body.formData.encoding === 'form-data' ? 'multipart/form-data' : 'application/x-www-form-urlencoded',
500
+ value: body.formData.value.flatMap((param) => param.key
501
+ ? {
502
+ name: param.key,
503
+ value: param.value,
504
+ isDisabled: !param.enabled,
505
+ }
506
+ : []),
507
+ };
443
508
  }
444
- const isTopLevelParent = topLevelTagUids.has(tagUid) && parentTagUids.has(tagUid);
445
- if (isTopLevelParent) {
446
- const childTagNames = tag.children.map((childUid) => dataRecords.tags[childUid]?.name).filter((name) => name !== void 0);
447
- if (childTagNames.length > 0) {
448
- tagGroups.push({
449
- name: tag.name,
450
- tags: childTagNames
451
- });
452
- }
453
- } else {
454
- const tagObject = { name: tag.name };
455
- if (tag.description) {
456
- tagObject.description = tag.description;
457
- }
458
- if (tag.externalDocs) {
459
- tagObject.externalDocs = tag.externalDocs;
460
- }
461
- tags.push(tagObject);
509
+ // Binary uploads have no inline content to migrate
510
+ if (body.activeBody === 'binary') {
511
+ return { contentType: 'binary', value: {} };
462
512
  }
463
- }
464
- return { tags, tagGroups };
513
+ return undefined;
465
514
  };
466
- const transformCollectionToDocument = (documentName, collection, dataRecords) => {
467
- const selectedServerUrl = collection.selectedServerUid && dataRecords.servers[collection.selectedServerUid] ? dataRecords.servers[collection.selectedServerUid]?.url : void 0;
468
- const { tags, tagGroups } = transformLegacyTags(collection, dataRecords);
469
- const { paths, extractedServers } = transformRequestsToPaths(collection, dataRecords);
470
- const existingServers = collection.servers.flatMap((uid) => {
471
- const server = dataRecords.servers[uid];
472
- if (!server) {
473
- return [];
515
+ /**
516
+ * Merges request examples into request body examples.
517
+ *
518
+ * The v2.5.0 data model stored request examples separately from the
519
+ * operation's requestBody. In the new model, examples live directly inside
520
+ * `requestBody.content[contentType].examples`. This function bridges the two
521
+ * by grouping examples by content type in a single pass and writing them into
522
+ * the requestBody structure.
523
+ *
524
+ * Returns the original requestBody unchanged when no examples have body content.
525
+ */
526
+ const mergeExamplesIntoRequestBody = (requestBody, requestExamples) => {
527
+ /**
528
+ * Single pass: extract each example body and bucket it by content type.
529
+ * Using a plain object as the inner value (instead of a nested Map) avoids
530
+ * a second conversion step when assigning to the result.
531
+ */
532
+ const groupedByContentType = new Map();
533
+ /** We track the selected content type for each example */
534
+ const selectedContentTypes = {};
535
+ const usedExampleNames = new Set();
536
+ for (const example of requestExamples) {
537
+ const extracted = extractBodyExample(example.body);
538
+ if (!extracted) {
539
+ continue;
540
+ }
541
+ const baseName = example.name || 'Example';
542
+ const name = ensureUniqueExampleName(baseName, usedExampleNames);
543
+ const group = groupedByContentType.get(extracted.contentType);
544
+ if (group) {
545
+ group[name] = { value: extracted.value };
546
+ }
547
+ else {
548
+ groupedByContentType.set(extracted.contentType, { [name]: { value: extracted.value } });
549
+ }
550
+ selectedContentTypes[name] = extracted.contentType;
474
551
  }
475
- const { uid: _, ...rest } = server;
476
- return [rest];
477
- });
478
- const allServers = [...existingServers, ...extractedServers];
479
- const seenUrls = /* @__PURE__ */ new Set();
480
- const deduplicatedServers = allServers.filter((server) => {
481
- if (seenUrls.has(server.url)) {
482
- return false;
552
+ // Nothing to merge return early so we do not mutate the requestBody
553
+ if (groupedByContentType.size === 0) {
554
+ return requestBody;
483
555
  }
484
- seenUrls.add(server.url);
485
- return true;
486
- });
487
- const document = {
488
- openapi: collection.openapi || "3.1.0",
489
- info: collection.info || {
490
- title: documentName,
491
- version: "1.0"
492
- },
493
- servers: deduplicatedServers,
494
- paths,
556
+ // Ensure the requestBody and its content map exist before writing
557
+ const result = requestBody ?? {};
558
+ result.content ??= {};
559
+ for (const [contentType, examples] of groupedByContentType) {
560
+ result.content[contentType] ??= {};
561
+ result.content[contentType].examples = examples;
562
+ }
563
+ // Add the x-scalar-selected-content-type mapping
564
+ if (Object.keys(selectedContentTypes).length > 0) {
565
+ result['x-scalar-selected-content-type'] = selectedContentTypes;
566
+ }
567
+ return result;
568
+ };
569
+ /**
570
+ * Transforms legacy tags into OpenAPI tags and tag groups.
571
+ *
572
+ * Legacy structure:
573
+ * - Tags can have children (nested tags)
574
+ * - Top-level parent tags become tag groups
575
+ * - Child tags and standalone tags become regular tags
576
+ */
577
+ const transformLegacyTags = (collection, dataRecords) => {
578
+ const tags = [];
579
+ const tagGroups = [];
495
580
  /**
496
- * Preserve all component types from the collection and merge with transformed security schemes.
497
- * OpenAPI components object supports: schemas, responses, parameters, examples,
498
- * requestBodies, headers, securitySchemes, links, callbacks, pathItems
581
+ * Identifies which tags are top-level (appear in collection.children).
582
+ * Top-level parent tags become tag groups, others become regular tags.
499
583
  */
500
- components: {
501
- // Preserve existing components from the collection (schemas, responses, parameters, etc.)
502
- ...collection.components || {},
503
- // Merge security schemes (transformed from UIDs) with any existing security schemes
504
- securitySchemes: {
505
- ...collection.components?.securitySchemes || {},
506
- ...collection.securitySchemes.reduce((acc, uid) => {
507
- const securityScheme = dataRecords.securitySchemes[uid];
508
- if (!securityScheme) {
509
- return acc;
510
- }
511
- const { uid: _uid, nameKey: _nameKey, ...publicSecurityScheme } = removeSecretFields(securityScheme);
512
- if (securityScheme.type === "oauth2") {
513
- const selectedScopes = /* @__PURE__ */ new Set();
514
- return {
515
- ...acc,
516
- [securityScheme.nameKey]: {
517
- ...publicSecurityScheme,
518
- flows: objectEntries(securityScheme.flows).reduce(
519
- (acc2, [key, flow]) => {
520
- if (!flow) {
521
- return acc2;
522
- }
523
- if ("selectedScopes" in flow && Array.isArray(flow.selectedScopes)) {
524
- flow.selectedScopes?.forEach((scope) => selectedScopes.add(scope));
525
- }
526
- acc2[key] = removeSecretFields(flow);
527
- return acc2;
528
- },
529
- {}
530
- ),
531
- "x-default-scopes": Array.from(selectedScopes)
532
- }
533
- };
534
- }
535
- return {
536
- ...acc,
537
- [securityScheme.nameKey]: publicSecurityScheme
538
- };
539
- }, {})
540
- }
541
- },
542
- security: collection.security || [],
543
- tags,
544
- webhooks: collection.webhooks,
545
- externalDocs: collection.externalDocs,
546
- // Preserve scalar extensions
547
- "x-scalar-icon": collection["x-scalar-icon"],
548
- // Convert legacy record-based environment variables to the new array format
549
- "x-scalar-environments": transformLegacyEnvironments(collection["x-scalar-environments"])
550
- };
551
- if (tagGroups.length > 0) {
552
- document["x-tagGroups"] = tagGroups;
553
- }
554
- if (collection["x-scalar-active-environment"]) {
555
- document["x-scalar-active-environment"] = collection["x-scalar-active-environment"];
556
- }
557
- if (selectedServerUrl) {
558
- document["x-scalar-selected-server"] = selectedServerUrl;
559
- }
560
- if (collection.documentUrl) {
561
- document["x-scalar-original-source-url"] = collection.documentUrl;
562
- }
563
- const safeDocument = toJsonCompatible(document);
564
- return {
565
- document: safeDocument,
566
- auth: coerceValue(AuthSchema, {
567
- secrets: collection.securitySchemes.reduce((acc, uid) => {
568
- const securityScheme = dataRecords.securitySchemes[uid];
569
- if (!securityScheme) {
570
- return acc;
584
+ const topLevelTagUids = new Set(collection.children.filter((uid) => dataRecords.tags[uid] !== undefined));
585
+ /**
586
+ * Identifies which tags have children.
587
+ * Only top-level parent tags become tag groups.
588
+ */
589
+ const parentTagUids = new Set(collection.tags.filter((uid) => {
590
+ const tag = dataRecords.tags[uid];
591
+ return tag?.children && tag.children.length > 0;
592
+ }));
593
+ /**
594
+ * Process each tag to create either a tag group or a regular tag.
595
+ */
596
+ for (const tagUid of collection.tags) {
597
+ const tag = dataRecords.tags[tagUid];
598
+ if (!tag) {
599
+ continue;
571
600
  }
572
- if (securityScheme.type === "oauth2") {
573
- return {
574
- ...acc,
575
- [securityScheme.nameKey]: {
576
- type: securityScheme.type,
577
- ...objectEntries(securityScheme.flows).reduce(
578
- (acc2, [key, flow]) => {
579
- if (!flow) {
580
- return acc2;
581
- }
582
- acc2[key] = extractConfigSecrets(flow);
583
- return acc2;
584
- },
585
- {}
586
- )
601
+ const isTopLevelParent = topLevelTagUids.has(tagUid) && parentTagUids.has(tagUid);
602
+ if (isTopLevelParent) {
603
+ /**
604
+ * Top-level parent tags become tag groups.
605
+ * Resolve child tag names, filtering out any missing children.
606
+ */
607
+ const childTagNames = tag.children
608
+ .map((childUid) => dataRecords.tags[childUid]?.name)
609
+ .filter((name) => name !== undefined);
610
+ if (childTagNames.length > 0) {
611
+ tagGroups.push({
612
+ name: tag.name,
613
+ tags: childTagNames,
614
+ });
587
615
  }
588
- };
589
616
  }
590
- return {
591
- ...acc,
592
- [securityScheme.nameKey]: {
593
- type: securityScheme.type,
594
- ...extractConfigSecrets(securityScheme)
595
- }
596
- };
597
- }, {}),
598
- selected: {}
599
- })
600
- };
617
+ else {
618
+ /**
619
+ * All other tags (child tags and standalone tags) become regular tags.
620
+ * Preserve optional fields from the legacy tag.
621
+ */
622
+ const tagObject = { name: tag.name };
623
+ if (tag.description) {
624
+ tagObject.description = tag.description;
625
+ }
626
+ if (tag.externalDocs) {
627
+ tagObject.externalDocs = tag.externalDocs;
628
+ }
629
+ tags.push(tagObject);
630
+ }
631
+ }
632
+ return { tags, tagGroups };
601
633
  };
602
- export {
603
- migrateLocalStorageToIndexDb,
604
- shouldMigrateToIndexDb,
605
- transformLegacyDataToWorkspace
634
+ /** Transforms a collection and everything it includes into a WorkspaceDocument + auth */
635
+ const transformCollectionToDocument = (documentName, collection, dataRecords) => {
636
+ // Resolve selectedServerUid → server URL for x-scalar-selected-server
637
+ const selectedServerUrl = collection.selectedServerUid && dataRecords.servers[collection.selectedServerUid]
638
+ ? dataRecords.servers[collection.selectedServerUid]?.url
639
+ : undefined;
640
+ // Transform tags: separate parent tags (groups) from child tags
641
+ const { tags, tagGroups } = transformLegacyTags(collection, dataRecords);
642
+ // Transform requests into paths and extract servers from full URLs
643
+ const { paths, extractedServers } = transformRequestsToPaths(collection, dataRecords);
644
+ /**
645
+ * Merge and deduplicate servers:
646
+ * 1. Start with existing collection servers
647
+ * 2. Add extracted servers from paths
648
+ * 3. Deduplicate by URL (keep first occurrence)
649
+ */
650
+ const existingServers = collection.servers.flatMap((uid) => {
651
+ const server = dataRecords.servers[uid];
652
+ if (!server) {
653
+ return [];
654
+ }
655
+ const { uid: _, ...rest } = server;
656
+ return [rest];
657
+ });
658
+ const allServers = [...existingServers, ...extractedServers];
659
+ const seenUrls = new Set();
660
+ const deduplicatedServers = allServers.filter((server) => {
661
+ if (seenUrls.has(server.url)) {
662
+ return false;
663
+ }
664
+ seenUrls.add(server.url);
665
+ return true;
666
+ });
667
+ const document = {
668
+ openapi: collection.openapi || '3.1.0',
669
+ info: collection.info || {
670
+ title: documentName,
671
+ version: '1.0',
672
+ },
673
+ servers: deduplicatedServers,
674
+ paths,
675
+ /**
676
+ * Preserve all component types from the collection and merge with transformed security schemes.
677
+ * OpenAPI components object supports: schemas, responses, parameters, examples,
678
+ * requestBodies, headers, securitySchemes, links, callbacks, pathItems
679
+ */
680
+ components: {
681
+ // Preserve existing components from the collection (schemas, responses, parameters, etc.)
682
+ ...(collection.components || {}),
683
+ // Merge security schemes (transformed from UIDs) with any existing security schemes
684
+ securitySchemes: {
685
+ ...(collection.components?.securitySchemes || {}),
686
+ ...collection.securitySchemes.reduce((acc, uid) => {
687
+ const securityScheme = dataRecords.securitySchemes[uid];
688
+ if (!securityScheme) {
689
+ return acc;
690
+ }
691
+ const { uid: _uid, nameKey: _nameKey, ...publicSecurityScheme } = removeSecretFields(securityScheme);
692
+ // Clean the flows
693
+ if (securityScheme.type === 'oauth2') {
694
+ const selectedScopes = new Set();
695
+ return {
696
+ ...acc,
697
+ [securityScheme.nameKey]: {
698
+ ...publicSecurityScheme,
699
+ flows: objectEntries(securityScheme.flows).reduce((acc, [key, flow]) => {
700
+ if (!flow) {
701
+ return acc;
702
+ }
703
+ // Store any selected scopes from the config
704
+ if ('selectedScopes' in flow && Array.isArray(flow.selectedScopes)) {
705
+ flow.selectedScopes?.forEach((scope) => selectedScopes.add(scope));
706
+ }
707
+ acc[key] = removeSecretFields(flow);
708
+ return acc;
709
+ }, {}),
710
+ 'x-default-scopes': Array.from(selectedScopes),
711
+ },
712
+ };
713
+ }
714
+ return {
715
+ ...acc,
716
+ [securityScheme.nameKey]: publicSecurityScheme,
717
+ };
718
+ }, {}),
719
+ },
720
+ },
721
+ security: collection.security || [],
722
+ tags,
723
+ webhooks: collection.webhooks,
724
+ externalDocs: collection.externalDocs,
725
+ // Preserve scalar extensions
726
+ 'x-scalar-icon': collection['x-scalar-icon'],
727
+ // Convert legacy record-based environment variables to the new array format
728
+ 'x-scalar-environments': transformLegacyEnvironments(collection['x-scalar-environments']),
729
+ };
730
+ // Add x-tagGroups if there are any parent tags
731
+ if (tagGroups.length > 0) {
732
+ document['x-tagGroups'] = tagGroups;
733
+ }
734
+ // x-scalar-active-environment
735
+ if (collection['x-scalar-active-environment']) {
736
+ document['x-scalar-active-environment'] = collection['x-scalar-active-environment'];
737
+ }
738
+ // selectedServerUid → x-scalar-selected-server (resolved to URL)
739
+ if (selectedServerUrl) {
740
+ document['x-scalar-selected-server'] = selectedServerUrl;
741
+ }
742
+ // documentUrl → x-scalar-original-source-url
743
+ if (collection.documentUrl) {
744
+ document['x-scalar-original-source-url'] = collection.documentUrl;
745
+ }
746
+ // Convert circular references to $ref pointers which is safe for JSON serialization
747
+ const safeDocument = toJsonCompatible(document);
748
+ return {
749
+ document: safeDocument,
750
+ auth: coerceValue(AuthSchema, {
751
+ secrets: collection.securitySchemes.reduce((acc, uid) => {
752
+ const securityScheme = dataRecords.securitySchemes[uid];
753
+ if (!securityScheme) {
754
+ return acc;
755
+ }
756
+ // Oauth 2
757
+ if (securityScheme.type === 'oauth2') {
758
+ return {
759
+ ...acc,
760
+ [securityScheme.nameKey]: {
761
+ type: securityScheme.type,
762
+ ...objectEntries(securityScheme.flows).reduce((acc, [key, flow]) => {
763
+ if (!flow) {
764
+ return acc;
765
+ }
766
+ acc[key] = extractConfigSecrets(flow);
767
+ return acc;
768
+ }, {}),
769
+ },
770
+ };
771
+ }
772
+ // The rest
773
+ return {
774
+ ...acc,
775
+ [securityScheme.nameKey]: {
776
+ type: securityScheme.type,
777
+ ...extractConfigSecrets(securityScheme),
778
+ },
779
+ };
780
+ }, {}),
781
+ selected: {},
782
+ }),
783
+ };
606
784
  };
607
- //# sourceMappingURL=migrate-to-indexdb.js.map