specra 0.2.6 → 0.2.8

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 (49) hide show
  1. package/config/specra.config.schema.json +23 -0
  2. package/config/svelte-config.js +32 -1
  3. package/dist/category.d.ts +1 -1
  4. package/dist/category.js +4 -2
  5. package/dist/components/docs/Breadcrumb.svelte +11 -4
  6. package/dist/components/docs/Breadcrumb.svelte.d.ts +1 -0
  7. package/dist/components/docs/CategoryIndex.svelte +11 -2
  8. package/dist/components/docs/CategoryIndex.svelte.d.ts +1 -0
  9. package/dist/components/docs/DocLayout.svelte +9 -8
  10. package/dist/components/docs/DocLayout.svelte.d.ts +1 -0
  11. package/dist/components/docs/DocNavigation.svelte +10 -3
  12. package/dist/components/docs/DocNavigation.svelte.d.ts +1 -0
  13. package/dist/components/docs/Header.svelte +17 -1
  14. package/dist/components/docs/Header.svelte.d.ts +10 -0
  15. package/dist/components/docs/MdxContent.svelte +3 -1
  16. package/dist/components/docs/MobileDocLayout.svelte +5 -1
  17. package/dist/components/docs/MobileDocLayout.svelte.d.ts +1 -0
  18. package/dist/components/docs/MobileSidebar.svelte +3 -1
  19. package/dist/components/docs/MobileSidebar.svelte.d.ts +1 -0
  20. package/dist/components/docs/MobileSidebarWrapper.svelte +3 -1
  21. package/dist/components/docs/MobileSidebarWrapper.svelte.d.ts +1 -0
  22. package/dist/components/docs/ProductSwitcher.svelte +175 -0
  23. package/dist/components/docs/ProductSwitcher.svelte.d.ts +28 -0
  24. package/dist/components/docs/SearchModal.svelte +4 -3
  25. package/dist/components/docs/Sidebar.svelte +3 -1
  26. package/dist/components/docs/Sidebar.svelte.d.ts +1 -0
  27. package/dist/components/docs/SidebarMenuItems.svelte +39 -9
  28. package/dist/components/docs/SidebarMenuItems.svelte.d.ts +1 -0
  29. package/dist/components/docs/TabGroups.svelte +9 -2
  30. package/dist/components/docs/TabGroups.svelte.d.ts +1 -0
  31. package/dist/components/docs/VersionSwitcher.svelte +1 -1
  32. package/dist/components/docs/api/ApiParams.svelte +94 -36
  33. package/dist/components/docs/index.d.ts +1 -0
  34. package/dist/components/docs/index.js +1 -0
  35. package/dist/components/ui/Button.svelte +30 -11
  36. package/dist/components/ui/Button.svelte.d.ts +10 -4
  37. package/dist/config.d.ts +1 -1
  38. package/dist/config.schema.json +18 -0
  39. package/dist/config.server.d.ts +35 -7
  40. package/dist/config.server.js +185 -23
  41. package/dist/config.types.d.ts +46 -0
  42. package/dist/mdx-cache.d.ts +3 -6
  43. package/dist/mdx-cache.js +36 -8
  44. package/dist/mdx.d.ts +8 -3
  45. package/dist/mdx.js +117 -10
  46. package/dist/parsers/openapi-parser.js +6 -1
  47. package/dist/redirects.d.ts +2 -1
  48. package/dist/redirects.js +37 -11
  49. package/package.json +1 -1
@@ -151,45 +151,68 @@ export function reloadConfig(userConfig) {
151
151
  */
152
152
  const versionConfigCache = new Map();
153
153
  const VCFG_TTL = process.env.NODE_ENV === 'development' ? 5000 : 60000;
154
- export function loadVersionConfig(version) {
155
- const cached = versionConfigCache.get(version);
154
+ export function loadVersionConfig(version, product) {
155
+ const cacheKey = product && product !== "_default_" ? `${product}:${version}` : version;
156
+ const cached = versionConfigCache.get(cacheKey);
156
157
  if (cached && Date.now() - cached.timestamp < VCFG_TTL) {
157
158
  return cached.data;
158
159
  }
159
160
  try {
160
- const versionConfigPath = path.join(process.cwd(), "docs", version, "_version_.json");
161
+ const basePath = (product && product !== "_default_")
162
+ ? path.join(process.cwd(), "docs", product, version)
163
+ : path.join(process.cwd(), "docs", version);
164
+ const versionConfigPath = path.join(basePath, "_version_.json");
161
165
  if (!fs.existsSync(versionConfigPath)) {
162
- versionConfigCache.set(version, { data: null, timestamp: Date.now() });
166
+ versionConfigCache.set(cacheKey, { data: null, timestamp: Date.now() });
163
167
  return null;
164
168
  }
165
169
  const content = fs.readFileSync(versionConfigPath, "utf8");
166
170
  const data = JSON.parse(content);
167
- versionConfigCache.set(version, { data, timestamp: Date.now() });
171
+ versionConfigCache.set(cacheKey, { data, timestamp: Date.now() });
168
172
  return data;
169
173
  }
170
174
  catch (error) {
171
- console.error(`Error loading _version_.json for ${version}:`, error);
172
- versionConfigCache.set(version, { data: null, timestamp: Date.now() });
175
+ console.error(`Error loading _version_.json for ${cacheKey}:`, error);
176
+ versionConfigCache.set(cacheKey, { data: null, timestamp: Date.now() });
173
177
  return null;
174
178
  }
175
179
  }
176
180
  /**
177
- * Get the effective config for a specific version.
178
- * Merges global config with per-version overrides from _version_.json.
179
- * If no _version_.json exists, returns the global config unchanged.
181
+ * Get the effective config for a specific version and optional product.
182
+ * Merges in priority order: global product ← version.
183
+ * If no overrides exist, returns the global config unchanged.
180
184
  */
181
- export function getEffectiveConfig(version) {
185
+ export function getEffectiveConfig(version, product) {
182
186
  const globalConfig = getConfig();
183
- const versionConfig = loadVersionConfig(version);
184
- if (!versionConfig) {
185
- return globalConfig;
186
- }
187
- const effective = { ...globalConfig };
188
- if (versionConfig.tabGroups !== undefined) {
189
- effective.navigation = {
190
- ...effective.navigation,
191
- tabGroups: versionConfig.tabGroups,
192
- };
187
+ let effective = { ...globalConfig };
188
+ // Layer 2: Product config overrides
189
+ if (product && product !== "_default_") {
190
+ const productConfig = loadProductConfig(product);
191
+ if (productConfig) {
192
+ if (productConfig.tabGroups !== undefined) {
193
+ effective.navigation = {
194
+ ...effective.navigation,
195
+ tabGroups: productConfig.tabGroups,
196
+ };
197
+ }
198
+ // Product's activeVersion overrides global
199
+ if (productConfig.activeVersion) {
200
+ effective.site = {
201
+ ...effective.site,
202
+ activeVersion: productConfig.activeVersion,
203
+ };
204
+ }
205
+ }
206
+ }
207
+ // Layer 3: Version config overrides (highest priority)
208
+ const versionConfig = loadVersionConfig(version, product);
209
+ if (versionConfig) {
210
+ if (versionConfig.tabGroups !== undefined) {
211
+ effective.navigation = {
212
+ ...effective.navigation,
213
+ tabGroups: versionConfig.tabGroups,
214
+ };
215
+ }
193
216
  }
194
217
  return effective;
195
218
  }
@@ -197,9 +220,9 @@ export function getEffectiveConfig(version) {
197
220
  * Get metadata for all versions, enriched with _version_.json data.
198
221
  * Hidden versions are included but marked — the UI decides whether to show them.
199
222
  */
200
- export function getVersionsMeta(versions) {
223
+ export function getVersionsMeta(versions, product) {
201
224
  return versions.map(id => {
202
- const versionConfig = loadVersionConfig(id);
225
+ const versionConfig = loadVersionConfig(id, product);
203
226
  return {
204
227
  id,
205
228
  label: versionConfig?.label || id,
@@ -209,6 +232,145 @@ export function getVersionsMeta(versions) {
209
232
  };
210
233
  });
211
234
  }
235
+ // ─── Product Detection & Loading ─────────────────────────────────────────────
236
+ /** Regex to detect version-like directory names (e.g., v1, v2.0.0) */
237
+ const VERSION_PATTERN = /^v\d/;
238
+ /** Cache for product scanning */
239
+ const productsCache = {
240
+ data: null,
241
+ timestamp: 0,
242
+ };
243
+ const PCFG_TTL = process.env.NODE_ENV === 'development' ? 5000 : 60000;
244
+ /** Cache for individual product configs */
245
+ const productConfigCache = new Map();
246
+ /**
247
+ * Load and parse a _product_.json file for a given product slug.
248
+ * Returns null if the file doesn't exist or is invalid.
249
+ */
250
+ export function loadProductConfig(product) {
251
+ const cached = productConfigCache.get(product);
252
+ if (cached && Date.now() - cached.timestamp < PCFG_TTL) {
253
+ return cached.data;
254
+ }
255
+ try {
256
+ const productConfigPath = path.join(process.cwd(), "docs", product, "_product_.json");
257
+ if (!fs.existsSync(productConfigPath)) {
258
+ productConfigCache.set(product, { data: null, timestamp: Date.now() });
259
+ return null;
260
+ }
261
+ const content = fs.readFileSync(productConfigPath, "utf8");
262
+ const data = JSON.parse(content);
263
+ if (!data.label) {
264
+ console.error(`[Specra] _product_.json in docs/${product}/ is missing required "label" field`);
265
+ productConfigCache.set(product, { data: null, timestamp: Date.now() });
266
+ return null;
267
+ }
268
+ productConfigCache.set(product, { data, timestamp: Date.now() });
269
+ return data;
270
+ }
271
+ catch (error) {
272
+ console.error(`[Specra] Error loading _product_.json for ${product}:`, error);
273
+ productConfigCache.set(product, { data: null, timestamp: Date.now() });
274
+ return null;
275
+ }
276
+ }
277
+ /**
278
+ * Scan docs/ top-level directories for _product_.json files.
279
+ * Returns the full list of products including the default product.
280
+ *
281
+ * Detection logic:
282
+ * 1. Single readdir + stat calls — no recursive walks
283
+ * 2. If no _product_.json found → single-product mode (returns empty array)
284
+ * 3. If any found → multi-product mode; bare version folders become the default product
285
+ * 4. Product slugs that match version patterns (e.g., v1.0.0) are rejected with a clear error
286
+ */
287
+ export function scanProducts() {
288
+ if (productsCache.data && Date.now() - productsCache.timestamp < PCFG_TTL) {
289
+ return productsCache.data;
290
+ }
291
+ const docsDir = path.join(process.cwd(), "docs");
292
+ const products = [];
293
+ let hasExplicitProducts = false;
294
+ try {
295
+ const entries = fs.readdirSync(docsDir, { withFileTypes: true });
296
+ for (const entry of entries) {
297
+ if (!entry.isDirectory())
298
+ continue;
299
+ const productJsonPath = path.join(docsDir, entry.name, "_product_.json");
300
+ if (!fs.existsSync(productJsonPath))
301
+ continue;
302
+ // Validate: product slugs must not look like version names
303
+ if (VERSION_PATTERN.test(entry.name)) {
304
+ console.error(`[Specra] Invalid product directory "docs/${entry.name}/": ` +
305
+ `product slugs must not start with "v" followed by digits (looks like a version). ` +
306
+ `Rename the directory or remove _product_.json.`);
307
+ continue;
308
+ }
309
+ const config = loadProductConfig(entry.name);
310
+ if (config) {
311
+ hasExplicitProducts = true;
312
+ products.push({
313
+ slug: entry.name,
314
+ config,
315
+ isDefault: false,
316
+ });
317
+ }
318
+ }
319
+ }
320
+ catch (error) {
321
+ console.error("[Specra] Error scanning for products:", error);
322
+ }
323
+ // Only build a product list if explicit products were found
324
+ if (!hasExplicitProducts) {
325
+ productsCache.data = [];
326
+ productsCache.timestamp = Date.now();
327
+ return [];
328
+ }
329
+ // Add the default product (bare version folders)
330
+ const globalConfig = getConfig();
331
+ const defaultProductConfig = globalConfig.site?.defaultProduct;
332
+ const defaultProduct = {
333
+ slug: "_default_",
334
+ config: {
335
+ label: defaultProductConfig?.label || globalConfig.site?.title || "Docs",
336
+ icon: defaultProductConfig?.icon,
337
+ activeVersion: defaultProductConfig?.activeVersion || globalConfig.site?.activeVersion,
338
+ position: -1, // Default product always first
339
+ },
340
+ isDefault: true,
341
+ };
342
+ const allProducts = [defaultProduct, ...products];
343
+ // Sort by position (lower first), then alphabetically by label
344
+ allProducts.sort((a, b) => {
345
+ const posA = a.config.position ?? 999;
346
+ const posB = b.config.position ?? 999;
347
+ if (posA !== posB)
348
+ return posA - posB;
349
+ return a.config.label.localeCompare(b.config.label);
350
+ });
351
+ productsCache.data = allProducts;
352
+ productsCache.timestamp = Date.now();
353
+ return allProducts;
354
+ }
355
+ /**
356
+ * Get all products (cached). Returns empty array in single-product mode.
357
+ */
358
+ export function getProducts() {
359
+ return scanProducts();
360
+ }
361
+ /**
362
+ * Check if the site is in multi-product mode.
363
+ */
364
+ export function isMultiProductMode() {
365
+ return getProducts().length > 0;
366
+ }
367
+ /**
368
+ * Clear product-related caches. Called by file watchers when _product_.json changes.
369
+ */
370
+ export function clearProductCaches() {
371
+ productsCache.data = null;
372
+ productConfigCache.clear();
373
+ }
212
374
  /**
213
375
  * Export the loaded config as default (SERVER ONLY)
214
376
  */
@@ -34,6 +34,8 @@ export interface SiteConfig {
34
34
  hideLogo?: boolean;
35
35
  /** Project ID to tie this doc site to a Specra project for visitor tracking */
36
36
  projectId?: string;
37
+ /** Configuration for the default product in multi-product mode */
38
+ defaultProduct?: DefaultProductConfig;
37
39
  }
38
40
  /**
39
41
  * Theme and appearance settings
@@ -84,6 +86,50 @@ export interface VersionConfig {
84
86
  /** Override tab groups for this version. Empty array = no tabs. */
85
87
  tabGroups?: TabGroup[];
86
88
  }
89
+ /**
90
+ * Per-product configuration loaded from docs/{product}/_product_.json
91
+ * Products sit above versions in the config hierarchy.
92
+ */
93
+ export interface ProductConfig {
94
+ /** Display name in the product switcher */
95
+ label: string;
96
+ /** Icon identifier for the product dropdown (lucide icon name) */
97
+ icon?: string;
98
+ /** Short description of the product */
99
+ description?: string;
100
+ /** Default version for this product (overrides global site.activeVersion) */
101
+ activeVersion?: string;
102
+ /** Badge text shown next to the product (e.g., "New", "Beta") */
103
+ badge?: string;
104
+ /** Order in the product dropdown (lower = first) */
105
+ position?: number;
106
+ /** Product-level tab group overrides */
107
+ tabGroups?: TabGroup[];
108
+ }
109
+ /**
110
+ * Resolved product with its slug and metadata.
111
+ * Returned by getProducts() — includes both filesystem identity and parsed config.
112
+ */
113
+ export interface Product {
114
+ /** Filesystem directory name used in URLs (e.g., "api", "sdk") */
115
+ slug: string;
116
+ /** Parsed product configuration from _product_.json */
117
+ config: ProductConfig;
118
+ /** Whether this is the default product (bare version folders, no _product_.json) */
119
+ isDefault: boolean;
120
+ }
121
+ /**
122
+ * Default product configuration in specra.config.json under site.defaultProduct.
123
+ * Used when the default product needs a custom label/icon instead of inheriting from site title.
124
+ */
125
+ export interface DefaultProductConfig {
126
+ /** Display name for the default product in the switcher */
127
+ label?: string;
128
+ /** Icon for the default product */
129
+ icon?: string;
130
+ /** Override active version for the default product */
131
+ activeVersion?: string;
132
+ }
87
133
  /**
88
134
  * Navigation and sidebar configuration
89
135
  */
@@ -6,18 +6,15 @@
6
6
  * invalidated automatically when files change.
7
7
  */
8
8
  import type { Doc } from './mdx';
9
- /**
10
- * Cached version of getVersions()
11
- */
12
- export declare function getCachedVersions(): string[];
9
+ export declare function getCachedVersions(product?: string): string[];
13
10
  /**
14
11
  * Cached version of getAllDocs()
15
12
  */
16
- export declare function getCachedAllDocs(version?: string, locale?: string): Promise<Doc[]>;
13
+ export declare function getCachedAllDocs(version?: string, locale?: string, product?: string): Promise<Doc[]>;
17
14
  /**
18
15
  * Cached version of getDocBySlug()
19
16
  */
20
- export declare function getCachedDocBySlug(slug: string, version?: string): Promise<Doc | null>;
17
+ export declare function getCachedDocBySlug(slug: string, version?: string, product?: string): Promise<Doc | null>;
21
18
  /**
22
19
  * Manually clear all caches
23
20
  * Useful for testing or when you want to force a refresh
package/dist/mdx-cache.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * invalidated automatically when files change.
7
7
  */
8
8
  import { getVersions, getAllDocs, getDocBySlug } from './mdx';
9
+ import { clearProductCaches } from './config.server';
9
10
  import { watch } from 'fs';
10
11
  import { join } from 'path';
11
12
  import { PerfTimer, logCacheOperation } from './dev-utils';
@@ -36,6 +37,11 @@ function initializeWatchers() {
36
37
  return;
37
38
  // Invalidate relevant caches when MDX or JSON files change
38
39
  if (filename.endsWith('.mdx') || filename.endsWith('.json')) {
40
+ // Clear product caches when _product_.json changes
41
+ if (filename.endsWith('_product_.json')) {
42
+ clearProductCaches();
43
+ console.log(`[MDX Cache] Product cache invalidated: ${filename}`);
44
+ }
39
45
  // Extract version from path
40
46
  const parts = filename.split(/[/\\]/);
41
47
  const version = parts[0];
@@ -52,6 +58,7 @@ function initializeWatchers() {
52
58
  // Clear versions cache if directory structure changed
53
59
  if (eventType === 'rename') {
54
60
  versionsCache.data = null;
61
+ clearProductCaches();
55
62
  }
56
63
  console.log(`[MDX Cache] Invalidated cache for: ${filename}`);
57
64
  }
@@ -69,11 +76,28 @@ function isCacheValid(timestamp) {
69
76
  return Date.now() - timestamp < CACHE_TTL;
70
77
  }
71
78
  /**
72
- * Cached version of getVersions()
79
+ * Cached version of getVersions().
80
+ * When product is provided, uses a separate cache key per product.
73
81
  */
74
- export function getCachedVersions() {
82
+ const productVersionsCache = new Map();
83
+ export function getCachedVersions(product) {
75
84
  // Initialize watchers on first use
76
85
  initializeWatchers();
86
+ // For product-scoped versions, use a separate cache
87
+ if (product && product !== "_default_") {
88
+ const cached = productVersionsCache.get(product);
89
+ if (cached && isCacheValid(cached.timestamp)) {
90
+ logCacheOperation('hit', `versions:${product}`);
91
+ return cached.data;
92
+ }
93
+ logCacheOperation('miss', `versions:${product}`);
94
+ const timer = new PerfTimer(`getVersions(${product})`);
95
+ const versions = getVersions(product);
96
+ timer.end();
97
+ productVersionsCache.set(product, { data: versions, timestamp: Date.now() });
98
+ return versions;
99
+ }
100
+ // Default product — original cache
77
101
  if (versionsCache.data && isCacheValid(versionsCache.timestamp)) {
78
102
  logCacheOperation('hit', 'versions');
79
103
  return versionsCache.data;
@@ -89,10 +113,11 @@ export function getCachedVersions() {
89
113
  /**
90
114
  * Cached version of getAllDocs()
91
115
  */
92
- export async function getCachedAllDocs(version = 'v1.0.0', locale) {
116
+ export async function getCachedAllDocs(version = 'v1.0.0', locale, product) {
93
117
  // Initialize watchers on first use
94
118
  initializeWatchers();
95
- const cacheKey = locale ? `${version}:${locale}` : version;
119
+ const productKey = (product && product !== "_default_") ? product : "_default_";
120
+ const cacheKey = locale ? `${productKey}:${version}:${locale}` : `${productKey}:${version}`;
96
121
  const cached = allDocsCache.get(cacheKey);
97
122
  if (cached && isCacheValid(cached.timestamp)) {
98
123
  logCacheOperation('hit', `getAllDocs:${cacheKey}`);
@@ -100,7 +125,7 @@ export async function getCachedAllDocs(version = 'v1.0.0', locale) {
100
125
  }
101
126
  logCacheOperation('miss', `getAllDocs:${cacheKey}`);
102
127
  const timer = new PerfTimer(`getAllDocs(${cacheKey})`);
103
- const docs = await getAllDocs(version, locale);
128
+ const docs = await getAllDocs(version, locale, product);
104
129
  timer.end();
105
130
  allDocsCache.set(cacheKey, {
106
131
  data: docs,
@@ -111,10 +136,11 @@ export async function getCachedAllDocs(version = 'v1.0.0', locale) {
111
136
  /**
112
137
  * Cached version of getDocBySlug()
113
138
  */
114
- export async function getCachedDocBySlug(slug, version = 'v1.0.0') {
139
+ export async function getCachedDocBySlug(slug, version = 'v1.0.0', product) {
115
140
  // Initialize watchers on first use
116
141
  initializeWatchers();
117
- const cacheKey = `${version}:${slug}`;
142
+ const productKey = (product && product !== "_default_") ? product : "_default_";
143
+ const cacheKey = `${productKey}:${version}:${slug}`;
118
144
  const cached = docBySlugCache.get(cacheKey);
119
145
  if (cached && isCacheValid(cached.timestamp)) {
120
146
  logCacheOperation('hit', `getDocBySlug:${cacheKey}`);
@@ -122,7 +148,7 @@ export async function getCachedDocBySlug(slug, version = 'v1.0.0') {
122
148
  }
123
149
  logCacheOperation('miss', `getDocBySlug:${cacheKey}`);
124
150
  const timer = new PerfTimer(`getDocBySlug(${slug})`);
125
- const doc = await getDocBySlug(slug, version);
151
+ const doc = await getDocBySlug(slug, version, undefined, product);
126
152
  timer.end();
127
153
  docBySlugCache.set(cacheKey, {
128
154
  data: doc,
@@ -136,8 +162,10 @@ export async function getCachedDocBySlug(slug, version = 'v1.0.0') {
136
162
  */
137
163
  export function clearAllCaches() {
138
164
  versionsCache.data = null;
165
+ productVersionsCache.clear();
139
166
  allDocsCache.clear();
140
167
  docBySlugCache.clear();
168
+ clearProductCaches();
141
169
  console.log('[MDX Cache] All caches cleared');
142
170
  }
143
171
  /**
package/dist/mdx.d.ts CHANGED
@@ -58,10 +58,15 @@ export interface TocItem {
58
58
  title: string;
59
59
  level: number;
60
60
  }
61
- export declare function getVersions(): string[];
61
+ export declare function getVersions(product?: string): string[];
62
+ /**
63
+ * Get versions scoped to a specific product.
64
+ * Convenience wrapper around getVersions() for product-aware code.
65
+ */
66
+ export declare function getProductVersions(product: string): string[];
62
67
  export declare function getI18nConfig(): I18nConfig | null;
63
- export declare function getDocBySlug(slug: string, version?: string, locale?: string): Promise<Doc | null>;
64
- export declare function getAllDocs(version?: string, locale?: string): Doc[];
68
+ export declare function getDocBySlug(slug: string, version?: string, locale?: string, product?: string): Promise<Doc | null>;
69
+ export declare function getAllDocs(version?: string, locale?: string, product?: string): Doc[];
65
70
  export declare function getAdjacentDocs(currentSlug: string, allDocs: Doc[]): {
66
71
  previous?: Doc;
67
72
  next?: Doc;
package/dist/mdx.js CHANGED
@@ -16,6 +16,17 @@ import { sortSidebarItems, sortSidebarGroups, buildSidebarStructure } from "./si
16
16
  import { sanitizePath, validatePathWithinDirectory, validateMDXSecurity } from "./mdx-security";
17
17
  import { getConfig } from "./config";
18
18
  const DOCS_DIR = path.join(process.cwd(), "docs");
19
+ /**
20
+ * Resolve the docs directory for a given version and optional product.
21
+ * - Default product or omitted: docs/{version}/
22
+ * - Named product: docs/{product}/{version}/
23
+ */
24
+ function resolveDocsPath(version, product) {
25
+ if (product && product !== "_default_") {
26
+ return path.join(DOCS_DIR, product, version);
27
+ }
28
+ return path.join(DOCS_DIR, version);
29
+ }
19
30
  /**
20
31
  * Map of lowercased HTML tag names to PascalCase component names.
21
32
  * When rehype processes MDX, it lowercases all custom tags.
@@ -368,7 +379,14 @@ function parseJsxExpression(expr) {
368
379
  return JSON.parse(trimmed);
369
380
  }
370
381
  catch {
371
- return trimmed;
382
+ // Convert JS object notation inside arrays to JSON
383
+ const jsonStr = trimmed.replace(/(\w+)\s*:/g, '"$1":').replace(/:\s*'([^']*)'/g, ': "$1"');
384
+ try {
385
+ return JSON.parse(jsonStr);
386
+ }
387
+ catch {
388
+ return trimmed;
389
+ }
372
390
  }
373
391
  }
374
392
  return trimmed;
@@ -438,6 +456,61 @@ function extractCodeBlockProps(node) {
438
456
  const filename = node.properties?.['data-filename'] || codeChild.properties?.['data-filename'];
439
457
  return { code, language, ...(filename ? { filename } : {}) };
440
458
  }
459
+ /**
460
+ * Detect GitHub-style alert blockquotes: > [!WARNING] content
461
+ * Returns the alert type and remaining content children, or null if not an alert.
462
+ */
463
+ const ALERT_TYPE_MAP = {
464
+ NOTE: 'note',
465
+ TIP: 'tip',
466
+ IMPORTANT: 'info',
467
+ WARNING: 'warning',
468
+ CAUTION: 'danger',
469
+ INFO: 'info',
470
+ SUCCESS: 'success',
471
+ ERROR: 'error',
472
+ DANGER: 'danger',
473
+ };
474
+ function extractBlockquoteAlert(node) {
475
+ if (node.type !== 'element' || node.tagName !== 'blockquote')
476
+ return null;
477
+ if (!node.children || node.children.length === 0)
478
+ return null;
479
+ // Find the first paragraph child
480
+ const firstP = node.children.find((c) => c.type === 'element' && c.tagName === 'p');
481
+ if (!firstP || !firstP.children || firstP.children.length === 0)
482
+ return null;
483
+ // Check first text node for [!TYPE] pattern
484
+ const firstText = firstP.children[0];
485
+ if (firstText.type !== 'text')
486
+ return null;
487
+ const match = firstText.value.match(/^\s*\[!(\w+)\]\s*\n?/);
488
+ if (!match)
489
+ return null;
490
+ const alertType = ALERT_TYPE_MAP[match[1].toUpperCase()];
491
+ if (!alertType)
492
+ return null;
493
+ // Build remaining content: modify the first paragraph to remove the alert marker
494
+ const remainingFirstPChildren = [...firstP.children];
495
+ const remainingText = firstText.value.slice(match[0].length);
496
+ if (remainingText.trim()) {
497
+ remainingFirstPChildren[0] = { ...firstText, value: remainingText };
498
+ }
499
+ else {
500
+ remainingFirstPChildren.shift();
501
+ }
502
+ const contentChildren = [];
503
+ if (remainingFirstPChildren.length > 0) {
504
+ contentChildren.push({ ...firstP, children: remainingFirstPChildren });
505
+ }
506
+ // Add any remaining blockquote children (paragraphs after the first)
507
+ for (const child of node.children) {
508
+ if (child !== firstP) {
509
+ contentChildren.push(child);
510
+ }
511
+ }
512
+ return { type: alertType, contentChildren };
513
+ }
441
514
  /**
442
515
  * Recursively extract text content from a hast node.
443
516
  */
@@ -709,6 +782,20 @@ async function hastChildrenToMdxNodes(children) {
709
782
  children: childNodes,
710
783
  });
711
784
  }
785
+ else if (extractBlockquoteAlert(child)) {
786
+ // GitHub-style alert blockquotes: > [!WARNING] content
787
+ flushHtmlBuffer();
788
+ const alert = extractBlockquoteAlert(child);
789
+ const contentNodes = alert.contentChildren.length > 0
790
+ ? await hastChildrenToMdxNodes(alert.contentChildren)
791
+ : [];
792
+ nodes.push({
793
+ type: 'component',
794
+ name: 'Callout',
795
+ props: { type: alert.type },
796
+ children: contentNodes,
797
+ });
798
+ }
712
799
  else {
713
800
  // Check if this regular element contains any component elements nested within
714
801
  if (hasNestedComponent(child)) {
@@ -981,15 +1068,35 @@ function calculateReadingTime(content) {
981
1068
  const minutes = Math.ceil(words / 200);
982
1069
  return { minutes, words };
983
1070
  }
984
- export function getVersions() {
1071
+ export function getVersions(product) {
985
1072
  try {
986
- const versions = fs.readdirSync(DOCS_DIR);
987
- return versions.filter((v) => fs.statSync(path.join(DOCS_DIR, v)).isDirectory());
1073
+ const baseDir = (product && product !== "_default_")
1074
+ ? path.join(DOCS_DIR, product)
1075
+ : DOCS_DIR;
1076
+ const entries = fs.readdirSync(baseDir);
1077
+ return entries.filter((v) => {
1078
+ if (!fs.statSync(path.join(baseDir, v)).isDirectory())
1079
+ return false;
1080
+ // In the default product's base dir, skip product directories (those with _product_.json)
1081
+ if (!product || product === "_default_") {
1082
+ const hasProductJson = fs.existsSync(path.join(baseDir, v, "_product_.json"));
1083
+ if (hasProductJson)
1084
+ return false;
1085
+ }
1086
+ return true;
1087
+ });
988
1088
  }
989
1089
  catch (error) {
990
1090
  return ["v1.0.0"];
991
1091
  }
992
1092
  }
1093
+ /**
1094
+ * Get versions scoped to a specific product.
1095
+ * Convenience wrapper around getVersions() for product-aware code.
1096
+ */
1097
+ export function getProductVersions(product) {
1098
+ return getVersions(product);
1099
+ }
993
1100
  /**
994
1101
  * Recursively find all MDX files in a directory
995
1102
  */
@@ -1094,7 +1201,7 @@ export function getI18nConfig() {
1094
1201
  }
1095
1202
  return i18n;
1096
1203
  }
1097
- export async function getDocBySlug(slug, version = "v1.0.0", locale) {
1204
+ export async function getDocBySlug(slug, version = "v1.0.0", locale, product) {
1098
1205
  try {
1099
1206
  // Security: Sanitize and validate slug
1100
1207
  const sanitizedVersion = sanitizePath(version);
@@ -1117,8 +1224,8 @@ export async function getDocBySlug(slug, version = "v1.0.0", locale) {
1117
1224
  // Try finding the file in this order:
1118
1225
  // 1. Localized extension: slug.locale.mdx (e.g. guide.fr.mdx)
1119
1226
  // 2. Default file: slug.mdx (only if using default locale and configured to fallback or strictly default)
1120
- // Construct potential paths
1121
- const basePath = path.join(DOCS_DIR, sanitizedVersion);
1227
+ // Construct potential paths — product-aware
1228
+ const basePath = resolveDocsPath(sanitizedVersion, product);
1122
1229
  let result = null;
1123
1230
  // 1. Try localized file extension
1124
1231
  if (targetLocale) {
@@ -1156,9 +1263,9 @@ export async function getDocBySlug(slug, version = "v1.0.0", locale) {
1156
1263
  return null;
1157
1264
  }
1158
1265
  }
1159
- export function getAllDocs(version = "v1.0.0", locale) {
1266
+ export function getAllDocs(version = "v1.0.0", locale, product) {
1160
1267
  try {
1161
- const versionDir = path.join(DOCS_DIR, version);
1268
+ const versionDir = resolveDocsPath(version, product);
1162
1269
  if (!fs.existsSync(versionDir)) {
1163
1270
  return [];
1164
1271
  }
@@ -1166,7 +1273,7 @@ export function getAllDocs(version = "v1.0.0", locale) {
1166
1273
  const i18nConfig = getI18nConfig();
1167
1274
  const targetLocale = locale || i18nConfig?.defaultLocale || 'en';
1168
1275
  const mdxFiles = findMdxFiles(versionDir);
1169
- const categoryConfigs = getAllCategoryConfigs(version);
1276
+ const categoryConfigs = getAllCategoryConfigs(version, product);
1170
1277
  const docs = mdxFiles.map((file) => {
1171
1278
  // file contains path relative to version dir, e.g. "getting-started/intro.mdx" or "intro.fr.mdx"
1172
1279
  let originalFilePath = file.replace(/\.mdx$/, "");
@@ -116,8 +116,13 @@ export class OpenApiParser {
116
116
  parseParameters(parameters, spec) {
117
117
  const result = { path: [], query: [], header: [] };
118
118
  for (const param of parameters) {
119
+ if (!param)
120
+ continue;
119
121
  // Resolve $ref if present
120
122
  const resolved = param.$ref ? this.resolveRef(param.$ref, spec) : param;
123
+ // Skip params that failed to resolve or have no name
124
+ if (!resolved || !resolved.name || !resolved.in)
125
+ continue;
121
126
  const apiParam = {
122
127
  name: resolved.name,
123
128
  type: resolved.schema?.type || resolved.type || "string",
@@ -202,7 +207,7 @@ export class OpenApiParser {
202
207
  for (const segment of path) {
203
208
  current = current[segment];
204
209
  if (!current)
205
- return {};
210
+ return null;
206
211
  }
207
212
  return current;
208
213
  }