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.
- package/config/specra.config.schema.json +23 -0
- package/config/svelte-config.js +32 -1
- 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 +11 -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/MdxContent.svelte +3 -1
- 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/SearchModal.svelte +4 -3
- package/dist/components/docs/Sidebar.svelte +3 -1
- package/dist/components/docs/Sidebar.svelte.d.ts +1 -0
- package/dist/components/docs/SidebarMenuItems.svelte +39 -9
- 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/api/ApiParams.svelte +94 -36
- package/dist/components/docs/index.d.ts +1 -0
- package/dist/components/docs/index.js +1 -0
- package/dist/components/ui/Button.svelte +30 -11
- package/dist/components/ui/Button.svelte.d.ts +10 -4
- 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 +117 -10
- package/dist/parsers/openapi-parser.js +6 -1
- package/dist/redirects.d.ts +2 -1
- package/dist/redirects.js +37 -11
- package/package.json +1 -1
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
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
987
|
-
|
|
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 =
|
|
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 =
|
|
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
|
}
|