specra 0.2.6 → 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.
- package/config/specra.config.schema.json +23 -0
- package/dist/category.d.ts +1 -1
- package/dist/category.js +4 -2
- package/dist/components/docs/Breadcrumb.svelte +11 -4
- package/dist/components/docs/Breadcrumb.svelte.d.ts +1 -0
- package/dist/components/docs/CategoryIndex.svelte +7 -2
- package/dist/components/docs/CategoryIndex.svelte.d.ts +1 -0
- package/dist/components/docs/DocLayout.svelte +9 -8
- package/dist/components/docs/DocLayout.svelte.d.ts +1 -0
- package/dist/components/docs/DocNavigation.svelte +10 -3
- package/dist/components/docs/DocNavigation.svelte.d.ts +1 -0
- package/dist/components/docs/Header.svelte +17 -1
- package/dist/components/docs/Header.svelte.d.ts +10 -0
- package/dist/components/docs/MobileDocLayout.svelte +5 -1
- package/dist/components/docs/MobileDocLayout.svelte.d.ts +1 -0
- package/dist/components/docs/MobileSidebar.svelte +3 -1
- package/dist/components/docs/MobileSidebar.svelte.d.ts +1 -0
- package/dist/components/docs/MobileSidebarWrapper.svelte +3 -1
- package/dist/components/docs/MobileSidebarWrapper.svelte.d.ts +1 -0
- package/dist/components/docs/ProductSwitcher.svelte +175 -0
- package/dist/components/docs/ProductSwitcher.svelte.d.ts +28 -0
- package/dist/components/docs/Sidebar.svelte +3 -1
- package/dist/components/docs/Sidebar.svelte.d.ts +1 -0
- package/dist/components/docs/SidebarMenuItems.svelte +16 -8
- package/dist/components/docs/SidebarMenuItems.svelte.d.ts +1 -0
- package/dist/components/docs/TabGroups.svelte +9 -2
- package/dist/components/docs/TabGroups.svelte.d.ts +1 -0
- package/dist/components/docs/VersionSwitcher.svelte +1 -1
- package/dist/components/docs/index.d.ts +1 -0
- package/dist/components/docs/index.js +1 -0
- package/dist/components/ui/Button.svelte.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.schema.json +18 -0
- package/dist/config.server.d.ts +35 -7
- package/dist/config.server.js +185 -23
- package/dist/config.types.d.ts +46 -0
- package/dist/mdx-cache.d.ts +3 -6
- package/dist/mdx-cache.js +36 -8
- package/dist/mdx.d.ts +8 -3
- package/dist/mdx.js +40 -9
- package/dist/redirects.d.ts +2 -1
- package/dist/redirects.js +37 -11
- 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 ===
|
|
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 =
|
|
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 =
|
|
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 ===
|
|
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 ===
|
|
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 =
|
|
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 =
|
|
327
|
+
{@const href = `${docsBase}/${doc.slug}`}
|
|
320
328
|
{@const isActive = pathname === href}
|
|
321
329
|
<a
|
|
322
330
|
{href}
|
|
@@ -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(
|
|
68
|
+
goto(`${docsBase}/${firstDocInTab.slug}`);
|
|
62
69
|
}
|
|
63
70
|
}
|
|
64
71
|
}
|
|
@@ -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?: "
|
|
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";
|
package/dist/config.schema.json
CHANGED
|
@@ -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
|
},
|
package/dist/config.server.d.ts
CHANGED
|
@@ -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
|
|
48
|
-
* If no
|
|
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
|
*/
|
package/dist/config.server.js
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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 ${
|
|
172
|
-
versionConfigCache.set(
|
|
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
|
|
179
|
-
* If no
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
*/
|
package/dist/config.types.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/mdx-cache.d.ts
CHANGED
|
@@ -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
|