specra 0.2.5 → 0.2.7

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 (44) hide show
  1. package/config/specra.config.schema.json +23 -0
  2. package/dist/category.d.ts +1 -1
  3. package/dist/category.js +4 -2
  4. package/dist/components/docs/Breadcrumb.svelte +11 -4
  5. package/dist/components/docs/Breadcrumb.svelte.d.ts +1 -0
  6. package/dist/components/docs/CategoryIndex.svelte +7 -2
  7. package/dist/components/docs/CategoryIndex.svelte.d.ts +1 -0
  8. package/dist/components/docs/DocLayout.svelte +9 -8
  9. package/dist/components/docs/DocLayout.svelte.d.ts +1 -0
  10. package/dist/components/docs/DocNavigation.svelte +10 -3
  11. package/dist/components/docs/DocNavigation.svelte.d.ts +1 -0
  12. package/dist/components/docs/Header.svelte +17 -1
  13. package/dist/components/docs/Header.svelte.d.ts +10 -0
  14. package/dist/components/docs/MobileDocLayout.svelte +5 -1
  15. package/dist/components/docs/MobileDocLayout.svelte.d.ts +1 -0
  16. package/dist/components/docs/MobileSidebar.svelte +3 -1
  17. package/dist/components/docs/MobileSidebar.svelte.d.ts +1 -0
  18. package/dist/components/docs/MobileSidebarWrapper.svelte +3 -1
  19. package/dist/components/docs/MobileSidebarWrapper.svelte.d.ts +1 -0
  20. package/dist/components/docs/ProductSwitcher.svelte +175 -0
  21. package/dist/components/docs/ProductSwitcher.svelte.d.ts +28 -0
  22. package/dist/components/docs/Sidebar.svelte +3 -1
  23. package/dist/components/docs/Sidebar.svelte.d.ts +1 -0
  24. package/dist/components/docs/SidebarMenuItems.svelte +16 -8
  25. package/dist/components/docs/SidebarMenuItems.svelte.d.ts +1 -0
  26. package/dist/components/docs/TabGroups.svelte +9 -2
  27. package/dist/components/docs/TabGroups.svelte.d.ts +1 -0
  28. package/dist/components/docs/VersionSwitcher.svelte +1 -1
  29. package/dist/components/docs/index.d.ts +1 -0
  30. package/dist/components/docs/index.js +1 -0
  31. package/dist/components/ui/Button.svelte.d.ts +1 -1
  32. package/dist/config.d.ts +1 -1
  33. package/dist/config.schema.json +18 -0
  34. package/dist/config.server.d.ts +35 -7
  35. package/dist/config.server.js +185 -23
  36. package/dist/config.types.d.ts +46 -0
  37. package/dist/mdx-cache.d.ts +3 -6
  38. package/dist/mdx-cache.js +36 -8
  39. package/dist/mdx.d.ts +8 -3
  40. package/dist/mdx.js +40 -9
  41. package/dist/redirects.d.ts +2 -1
  42. package/dist/redirects.js +37 -11
  43. package/dist/styles/globals.css +2 -1
  44. package/package.json +1 -1
@@ -42,12 +42,20 @@
42
42
  interface Props {
43
43
  docs: DocItem[];
44
44
  version: string;
45
+ product?: string;
45
46
  onLinkClick?: () => void;
46
47
  config: SpecraConfig;
47
48
  activeTabGroup?: string;
48
49
  }
49
50
 
50
- let { docs, version, onLinkClick, config, activeTabGroup }: Props = $props();
51
+ let { docs = [], version, product, onLinkClick, config, activeTabGroup }: Props = $props();
52
+
53
+ /** URL prefix: /docs/{product}/{version} for named products, /docs/{version} for default */
54
+ let docsBase = $derived(
55
+ product && product !== '_default_'
56
+ ? `/docs/${product}/${version}`
57
+ : `/docs/${version}`
58
+ );
51
59
 
52
60
  let collapsed: Record<string, boolean> = $state({});
53
61
  let pathname = $derived($page.url.pathname.replace(/\/$/, ''));
@@ -176,14 +184,14 @@
176
184
 
177
185
  function isActiveInGroup(group: SidebarGroup): boolean {
178
186
  const hasActiveItem = group.items.some(
179
- (doc) => pathname === `/docs/${version}/${doc.slug}`
187
+ (doc) => pathname === `${docsBase}/${doc.slug}`
180
188
  );
181
189
  if (hasActiveItem) return true;
182
190
  return Object.values(group.children).some((child) => isActiveInGroup(child));
183
191
  }
184
192
 
185
193
  function getGroupHref(group: SidebarGroup): string {
186
- let groupHref = `/docs/${version}/${group.path}`;
194
+ let groupHref = `${docsBase}/${group.path}`;
187
195
 
188
196
  if (config.features?.i18n) {
189
197
  const i18n = config.features.i18n;
@@ -192,7 +200,7 @@
192
200
  const potentialLocale = pathParts[3];
193
201
 
194
202
  if (potentialLocale && locales.includes(potentialLocale)) {
195
- groupHref = `/docs/${version}/${potentialLocale}/${group.path}`;
203
+ groupHref = `${docsBase}/${potentialLocale}/${group.path}`;
196
204
  }
197
205
  }
198
206
 
@@ -201,7 +209,7 @@
201
209
 
202
210
  function isGroupCollapsed(groupKey: string, group: SidebarGroup): boolean {
203
211
  const hasActive = isActiveInGroup(group);
204
- const isGroupActive = pathname === `/docs/${version}/${group.path}`;
212
+ const isGroupActive = pathname === `${docsBase}/${group.path}`;
205
213
  if (hasActive || isGroupActive) return false;
206
214
  return collapsed[groupKey] ?? group.defaultCollapsed;
207
215
  }
@@ -240,7 +248,7 @@
240
248
  {@const hasChildren = sortedChildren.length > 0}
241
249
  {@const hasItems = sortedItems.length > 0}
242
250
  {@const hasContent = hasChildren || hasItems}
243
- {@const isGroupActive = pathname === `/docs/${version}/${group.path}`}
251
+ {@const isGroupActive = pathname === `${docsBase}/${group.path}`}
244
252
  {@const isCollapsed = isGroupCollapsed(groupKey, group)}
245
253
  {@const marginLeft = depth > 0 ? 'ml-4' : ''}
246
254
  {@const groupHref = getGroupHref(group)}
@@ -289,7 +297,7 @@
289
297
  {#if item.type === 'group'}
290
298
  {@render renderGroup(`${groupKey}/${item.key}`, item.group, depth + 1)}
291
299
  {:else}
292
- {@const href = `/docs/${version}/${item.doc.slug}`}
300
+ {@const href = `${docsBase}/${item.doc.slug}`}
293
301
  {@const isActive = pathname === href}
294
302
  <a
295
303
  {href}
@@ -316,7 +324,7 @@
316
324
  <nav class="space-y-1">
317
325
  {#if sortedStandalone.length > 0}
318
326
  {#each sortedStandalone as doc (doc.slug)}
319
- {@const href = `/docs/${version}/${doc.slug}`}
327
+ {@const href = `${docsBase}/${doc.slug}`}
320
328
  {@const isActive = pathname === href}
321
329
  <a
322
330
  {href}
@@ -24,6 +24,7 @@ interface DocItem {
24
24
  interface Props {
25
25
  docs: DocItem[];
26
26
  version: string;
27
+ product?: string;
27
28
  onLinkClick?: () => void;
28
29
  config: SpecraConfig;
29
30
  activeTabGroup?: string;
@@ -24,10 +24,17 @@
24
24
  mobileOnly?: boolean;
25
25
  docs?: DocItem[];
26
26
  version?: string;
27
+ product?: string;
27
28
  flush?: boolean;
28
29
  }
29
30
 
30
- let { tabGroups, activeTabId, onTabChange, mobileOnly = false, docs, version, flush = false }: Props = $props();
31
+ let { tabGroups, activeTabId, onTabChange, mobileOnly = false, docs, version, product, flush = false }: Props = $props();
32
+
33
+ let docsBase = $derived(
34
+ product && product !== '_default_' && version
35
+ ? `/docs/${product}/${version}`
36
+ : version ? `/docs/${version}` : '/docs'
37
+ );
31
38
 
32
39
  let dropdownOpen = $state(false);
33
40
 
@@ -58,7 +65,7 @@
58
65
  });
59
66
 
60
67
  if (firstDocInTab) {
61
- goto(`/docs/${version}/${firstDocInTab.slug}`);
68
+ goto(`${docsBase}/${firstDocInTab.slug}`);
62
69
  }
63
70
  }
64
71
  }
@@ -18,6 +18,7 @@ interface Props {
18
18
  mobileOnly?: boolean;
19
19
  docs?: DocItem[];
20
20
  version?: string;
21
+ product?: string;
21
22
  flush?: boolean;
22
23
  }
23
24
  declare const TabGroups: import("svelte").Component<Props, {}, "">;
@@ -27,7 +27,7 @@
27
27
  if (versionsMeta && versionsMeta.length > 0) {
28
28
  return versionsMeta.filter(v => !v.hidden);
29
29
  }
30
- return versions.map(id => ({ id, label: id }));
30
+ return (versions ?? []).map(id => ({ id, label: id }));
31
31
  });
32
32
 
33
33
  // Get current version display label
@@ -51,6 +51,7 @@ export { default as TimelineItem } from './TimelineItem.svelte';
51
51
  export { default as Tabs } from './Tabs.svelte';
52
52
  export { default as ThemeToggle } from './ThemeToggle.svelte';
53
53
  export { default as Tooltip } from './Tooltip.svelte';
54
+ export { default as ProductSwitcher } from './ProductSwitcher.svelte';
54
55
  export { default as VersionBanner } from './VersionBanner.svelte';
55
56
  export { default as VersionSwitcher } from './VersionSwitcher.svelte';
56
57
  export { default as Video } from './Video.svelte';
@@ -52,6 +52,7 @@ export { default as TimelineItem } from './TimelineItem.svelte';
52
52
  export { default as Tabs } from './Tabs.svelte';
53
53
  export { default as ThemeToggle } from './ThemeToggle.svelte';
54
54
  export { default as Tooltip } from './Tooltip.svelte';
55
+ export { default as ProductSwitcher } from './ProductSwitcher.svelte';
55
56
  export { default as VersionBanner } from './VersionBanner.svelte';
56
57
  export { default as VersionSwitcher } from './VersionSwitcher.svelte';
57
58
  export { default as Video } from './Video.svelte';
@@ -1,7 +1,7 @@
1
1
  import { type VariantProps } from 'class-variance-authority';
2
2
  export declare const buttonVariants: (props?: ({
3
3
  variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
4
- size?: "default" | "icon" | "sm" | "lg" | "icon-sm" | "icon-lg" | null | undefined;
4
+ size?: "icon" | "default" | "sm" | "lg" | "icon-sm" | "icon-lg" | null | undefined;
5
5
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
6
  export type ButtonVariants = VariantProps<typeof buttonVariants>;
7
7
  import type { HTMLButtonAttributes } from 'svelte/elements';
package/dist/config.d.ts CHANGED
@@ -4,5 +4,5 @@
4
4
  * The actual config loading happens on the server and is passed as props
5
5
  */
6
6
  export { defaultConfig } from "./config.types";
7
- export type { SpecraConfig, VersionConfig, BannerConfig } from "./config.types";
7
+ export type { SpecraConfig, VersionConfig, BannerConfig, ProductConfig, Product, DefaultProductConfig } from "./config.types";
8
8
  export { getConfig, getConfigValue, loadConfig, processContentWithEnv, replaceEnvVariables, validateConfig, reloadConfig, } from "./config.server";
@@ -74,6 +74,24 @@
74
74
  "hideLogo": {
75
75
  "type": "boolean",
76
76
  "description": "Whether to hide the site logo in the header"
77
+ },
78
+ "defaultProduct": {
79
+ "type": "object",
80
+ "description": "Configuration for the default product in multi-product mode. Falls back to site title and activeVersion if not set.",
81
+ "properties": {
82
+ "label": {
83
+ "type": "string",
84
+ "description": "Display name for the default product in the switcher"
85
+ },
86
+ "icon": {
87
+ "type": "string",
88
+ "description": "Icon for the default product (lucide icon name)"
89
+ },
90
+ "activeVersion": {
91
+ "type": "string",
92
+ "description": "Override active version for the default product"
93
+ }
94
+ }
77
95
  }
78
96
  }
79
97
  },
@@ -1,4 +1,4 @@
1
- import type { SpecraConfig, VersionConfig } from "./config.types";
1
+ import type { SpecraConfig, VersionConfig, ProductConfig, Product } from "./config.types";
2
2
  /**
3
3
  * Load and parse the Specra configuration file
4
4
  * Falls back to default configuration if file doesn't exist or is invalid
@@ -41,13 +41,13 @@ export declare function getConfig(): SpecraConfig;
41
41
  * Reload the configuration (useful for development) (SERVER ONLY)
42
42
  */
43
43
  export declare function reloadConfig(userConfig: Partial<SpecraConfig>): SpecraConfig;
44
- export declare function loadVersionConfig(version: string): VersionConfig | null;
44
+ export declare function loadVersionConfig(version: string, product?: string): VersionConfig | null;
45
45
  /**
46
- * Get the effective config for a specific version.
47
- * Merges global config with per-version overrides from _version_.json.
48
- * If no _version_.json exists, returns the global config unchanged.
46
+ * Get the effective config for a specific version and optional product.
47
+ * Merges in priority order: global product ← version.
48
+ * If no overrides exist, returns the global config unchanged.
49
49
  */
50
- export declare function getEffectiveConfig(version: string): SpecraConfig;
50
+ export declare function getEffectiveConfig(version: string, product?: string): SpecraConfig;
51
51
  /**
52
52
  * Version metadata for display in the version switcher.
53
53
  */
@@ -67,7 +67,35 @@ export interface VersionMeta {
67
67
  * Get metadata for all versions, enriched with _version_.json data.
68
68
  * Hidden versions are included but marked — the UI decides whether to show them.
69
69
  */
70
- export declare function getVersionsMeta(versions: string[]): VersionMeta[];
70
+ export declare function getVersionsMeta(versions: string[], product?: string): VersionMeta[];
71
+ /**
72
+ * Load and parse a _product_.json file for a given product slug.
73
+ * Returns null if the file doesn't exist or is invalid.
74
+ */
75
+ export declare function loadProductConfig(product: string): ProductConfig | null;
76
+ /**
77
+ * Scan docs/ top-level directories for _product_.json files.
78
+ * Returns the full list of products including the default product.
79
+ *
80
+ * Detection logic:
81
+ * 1. Single readdir + stat calls — no recursive walks
82
+ * 2. If no _product_.json found → single-product mode (returns empty array)
83
+ * 3. If any found → multi-product mode; bare version folders become the default product
84
+ * 4. Product slugs that match version patterns (e.g., v1.0.0) are rejected with a clear error
85
+ */
86
+ export declare function scanProducts(): Product[];
87
+ /**
88
+ * Get all products (cached). Returns empty array in single-product mode.
89
+ */
90
+ export declare function getProducts(): Product[];
91
+ /**
92
+ * Check if the site is in multi-product mode.
93
+ */
94
+ export declare function isMultiProductMode(): boolean;
95
+ /**
96
+ * Clear product-related caches. Called by file watchers when _product_.json changes.
97
+ */
98
+ export declare function clearProductCaches(): void;
71
99
  /**
72
100
  * Export the loaded config as default (SERVER ONLY)
73
101
  */
@@ -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