@f5-sales-demo/docs-theme 3.9.0
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/LICENSE +21 -0
- package/README.md +36 -0
- package/assets/f5-distributed-cloud.svg +1 -0
- package/assets/f5-logo.svg +1 -0
- package/assets/github-avatar.png +0 -0
- package/astro.config.mjs +3 -0
- package/bin/translate.mjs +113 -0
- package/components/Banner.astro +51 -0
- package/components/EditLink.astro +3 -0
- package/components/Footer.astro +47 -0
- package/components/Icon.astro +100 -0
- package/components/LinkCard.astro +110 -0
- package/components/MarkdownContent.astro +7 -0
- package/components/Screenshot.astro +31 -0
- package/components/SiteTitle.astro +56 -0
- package/config.ts +816 -0
- package/fonts/font-face.css +79 -0
- package/fonts/neusaNextProWide-300.woff2 +0 -0
- package/fonts/neusaNextProWide-400.woff2 +0 -0
- package/fonts/neusaNextProWide-400i.woff2 +0 -0
- package/fonts/neusaNextProWide-500.woff2 +0 -0
- package/fonts/neusaNextProWide-700.woff2 +0 -0
- package/fonts/neusaNextProWide-700i.woff2 +0 -0
- package/fonts/proximaNova-400.woff2 +0 -0
- package/fonts/proximaNova-500.woff2 +0 -0
- package/fonts/proximaNova-600.woff2 +0 -0
- package/fonts/proximaNova-700.woff2 +0 -0
- package/index.ts +36 -0
- package/package.json +135 -0
- package/route-middleware.ts +73 -0
- package/src/content.config.ts +10 -0
- package/src/i18n/glossary.ts +18 -0
- package/src/i18n/locales.test.ts +56 -0
- package/src/i18n/locales.ts +7 -0
- package/src/i18n/mega-menu-translations.ts +1363 -0
- package/src/i18n/prompt.ts +30 -0
- package/src/i18n/translations.ts +733 -0
- package/src/i18n/translator.ts +120 -0
- package/src/plugins/remark-mermaid.mjs +24 -0
- package/src/types/icon.ts +6 -0
- package/src/utils/localize-ecosystem-href.test.ts +65 -0
- package/src/utils/localize-ecosystem-href.ts +34 -0
- package/src/utils/resolve-icon.ts +70 -0
- package/src/utils/subcategory-sidebar.test.ts +25 -0
- package/src/utils/subcategory-sidebar.ts +316 -0
- package/styles/custom.css +807 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import { getGlossary } from './glossary.ts';
|
|
6
|
+
import { buildTranslationPrompt } from './prompt.ts';
|
|
7
|
+
|
|
8
|
+
export interface TranslateOptions {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
contentDir: string;
|
|
12
|
+
force?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function computeSourceHash(content: string): string {
|
|
16
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 12);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function needsTranslation(englishContent: string, targetPath: string): boolean {
|
|
20
|
+
if (!fs.existsSync(targetPath)) return true;
|
|
21
|
+
|
|
22
|
+
const targetRaw = fs.readFileSync(targetPath, 'utf-8');
|
|
23
|
+
const { data: targetFrontmatter } = matter(targetRaw);
|
|
24
|
+
|
|
25
|
+
if (targetFrontmatter.i18n?.translator !== 'machine') {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const currentHash = computeSourceHash(englishContent);
|
|
30
|
+
return targetFrontmatter.i18n.sourceHash !== currentHash;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function translateFile(
|
|
34
|
+
englishPath: string,
|
|
35
|
+
localeCode: string,
|
|
36
|
+
localeName: string,
|
|
37
|
+
options: TranslateOptions,
|
|
38
|
+
): Promise<string | null> {
|
|
39
|
+
const englishRaw = fs.readFileSync(englishPath, 'utf-8');
|
|
40
|
+
const { data: frontmatter } = matter(englishRaw);
|
|
41
|
+
|
|
42
|
+
const relativePath = path.relative(path.join(options.contentDir, 'en'), englishPath);
|
|
43
|
+
const targetPath = path.join(options.contentDir, localeCode, relativePath);
|
|
44
|
+
|
|
45
|
+
if (!options.force && !needsTranslation(englishRaw, targetPath)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
50
|
+
const client = new Anthropic({ apiKey: options.apiKey });
|
|
51
|
+
|
|
52
|
+
const glossary = getGlossary(localeCode);
|
|
53
|
+
const systemPrompt = buildTranslationPrompt(localeName, localeCode, glossary);
|
|
54
|
+
const sourceLength = englishRaw.length;
|
|
55
|
+
|
|
56
|
+
let translated: string;
|
|
57
|
+
let attempts = 0;
|
|
58
|
+
const maxAttempts = 3;
|
|
59
|
+
|
|
60
|
+
while (true) {
|
|
61
|
+
attempts++;
|
|
62
|
+
try {
|
|
63
|
+
const stream = client.messages.stream({
|
|
64
|
+
model: options.model || process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6',
|
|
65
|
+
max_tokens: Math.max(4096, Math.ceil(sourceLength * 2.5)),
|
|
66
|
+
system: systemPrompt,
|
|
67
|
+
messages: [{ role: 'user', content: englishRaw }],
|
|
68
|
+
});
|
|
69
|
+
const response = await stream.finalMessage();
|
|
70
|
+
|
|
71
|
+
const textBlock = response.content.find((b) => b.type === 'text');
|
|
72
|
+
if (textBlock?.type !== 'text') {
|
|
73
|
+
throw new Error('No text block in API response');
|
|
74
|
+
}
|
|
75
|
+
translated = textBlock.text;
|
|
76
|
+
break;
|
|
77
|
+
} catch (err: unknown) {
|
|
78
|
+
const error = err as Error & { status?: number };
|
|
79
|
+
if (error.status === 429 && attempts < maxAttempts) {
|
|
80
|
+
const delay = 2 ** attempts * 1000;
|
|
81
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
console.warn(`[translate] Failed to translate ${relativePath} to ${localeCode}: ${error.message}`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { data: translatedFrontmatter, content: translatedBody } = matter(translated);
|
|
90
|
+
const sourceHash = computeSourceHash(englishRaw);
|
|
91
|
+
|
|
92
|
+
const mergedFrontmatter = { ...frontmatter };
|
|
93
|
+
if (translatedFrontmatter.title) mergedFrontmatter.title = translatedFrontmatter.title;
|
|
94
|
+
if (translatedFrontmatter.description) mergedFrontmatter.description = translatedFrontmatter.description;
|
|
95
|
+
if (translatedFrontmatter.sidebar?.label) {
|
|
96
|
+
mergedFrontmatter.sidebar = { ...mergedFrontmatter.sidebar, label: translatedFrontmatter.sidebar.label };
|
|
97
|
+
}
|
|
98
|
+
if (translatedFrontmatter.hero) {
|
|
99
|
+
mergedFrontmatter.hero = { ...mergedFrontmatter.hero };
|
|
100
|
+
if (translatedFrontmatter.hero.tagline) mergedFrontmatter.hero.tagline = translatedFrontmatter.hero.tagline;
|
|
101
|
+
if (translatedFrontmatter.hero.title) mergedFrontmatter.hero.title = translatedFrontmatter.hero.title;
|
|
102
|
+
if (translatedFrontmatter.hero.actions) {
|
|
103
|
+
mergedFrontmatter.hero.actions = mergedFrontmatter.hero.actions?.map(
|
|
104
|
+
(action: Record<string, unknown>, i: number) => ({
|
|
105
|
+
...action,
|
|
106
|
+
text: translatedFrontmatter.hero.actions?.[i]?.text || action.text,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
mergedFrontmatter.i18n = { sourceHash, translator: 'machine' };
|
|
113
|
+
|
|
114
|
+
const output = matter.stringify(translatedBody, mergedFrontmatter);
|
|
115
|
+
|
|
116
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
117
|
+
fs.writeFileSync(targetPath, output, 'utf-8');
|
|
118
|
+
|
|
119
|
+
return targetPath;
|
|
120
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
|
|
3
|
+
export default function remarkMermaid() {
|
|
4
|
+
return (tree) => {
|
|
5
|
+
visit(tree, 'code', (node, index, parent) => {
|
|
6
|
+
if (node.lang !== 'mermaid') return;
|
|
7
|
+
if (index === undefined || !parent) return;
|
|
8
|
+
|
|
9
|
+
const escaped = node.value
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"');
|
|
14
|
+
|
|
15
|
+
parent.children[index] = {
|
|
16
|
+
type: 'html',
|
|
17
|
+
value: `<div class="mermaid-container" data-mermaid-src="${escaped}"><pre class="mermaid">${escaped}</pre></div>`,
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Note: Mermaid CDN script is loaded via Starlight head config in astro.config.mjs.
|
|
22
|
+
// Script injection via remark HTML nodes is stripped by MDX/Astro for security.
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { langToSlug, localizeEcosystemHref } from './localize-ecosystem-href';
|
|
3
|
+
|
|
4
|
+
describe('langToSlug', () => {
|
|
5
|
+
it('converts BCP-47 codes with region to lowercase slugs', () => {
|
|
6
|
+
expect(langToSlug('pt-BR')).toBe('pt-br');
|
|
7
|
+
expect(langToSlug('zh-CN')).toBe('zh-cn');
|
|
8
|
+
expect(langToSlug('zh-TW')).toBe('zh-tw');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('passes through simple language codes unchanged', () => {
|
|
12
|
+
expect(langToSlug('en')).toBe('en');
|
|
13
|
+
expect(langToSlug('fr')).toBe('fr');
|
|
14
|
+
expect(langToSlug('ar')).toBe('ar');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('lowercases unknown codes as fallback', () => {
|
|
18
|
+
expect(langToSlug('sv-SE')).toBe('sv-se');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('localizeEcosystemHref', () => {
|
|
23
|
+
it('injects locale slug into ecosystem URLs', () => {
|
|
24
|
+
const result = localizeEcosystemHref('https://f5xc-salesdemos.github.io/waf/', 'fr');
|
|
25
|
+
expect(result).toBe('https://f5xc-salesdemos.github.io/waf/fr/');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does not double-inject if locale already present', () => {
|
|
29
|
+
const result = localizeEcosystemHref('https://f5xc-salesdemos.github.io/waf/fr/', 'fr');
|
|
30
|
+
expect(result).toBe('https://f5xc-salesdemos.github.io/waf/fr/');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns href unchanged for non-ecosystem hosts', () => {
|
|
34
|
+
const result = localizeEcosystemHref('https://example.com/waf/', 'fr');
|
|
35
|
+
expect(result).toBe('https://example.com/waf/');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns href unchanged for invalid locale slug', () => {
|
|
39
|
+
const result = localizeEcosystemHref('https://f5xc-salesdemos.github.io/waf/', 'xx-invalid');
|
|
40
|
+
expect(result).toBe('https://f5xc-salesdemos.github.io/waf/');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns href unchanged for empty locale', () => {
|
|
44
|
+
const result = localizeEcosystemHref('https://f5xc-salesdemos.github.io/waf/', '');
|
|
45
|
+
expect(result).toBe('https://f5xc-salesdemos.github.io/waf/');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns invalid URLs unchanged', () => {
|
|
49
|
+
const result = localizeEcosystemHref('not-a-url', 'fr');
|
|
50
|
+
expect(result).toBe('not-a-url');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('works with all 13 supported locale slugs', () => {
|
|
54
|
+
const slugs = ['en', 'fr', 'es', 'de', 'pt-br', 'ja', 'ko', 'zh-cn', 'zh-tw', 'ar', 'it', 'hi', 'th'];
|
|
55
|
+
for (const slug of slugs) {
|
|
56
|
+
const result = localizeEcosystemHref('https://f5xc-salesdemos.github.io/waf/', slug);
|
|
57
|
+
expect(result).toContain(`/${slug}/`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('supports custom ecosystem host', () => {
|
|
62
|
+
const result = localizeEcosystemHref('https://custom.host.io/waf/', 'fr', 'custom.host.io');
|
|
63
|
+
expect(result).toBe('https://custom.host.io/waf/fr/');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { bcp47ToSlug, VALID_SLUGS } from '@f5xc-salesdemos/i18n-core';
|
|
2
|
+
|
|
3
|
+
const ECOSYSTEM_HOST = 'f5xc-salesdemos.github.io';
|
|
4
|
+
|
|
5
|
+
export const langToSlug = bcp47ToSlug;
|
|
6
|
+
|
|
7
|
+
export function localizeEcosystemHref(
|
|
8
|
+
href: string,
|
|
9
|
+
localeSlug: string,
|
|
10
|
+
ecosystemHost: string = ECOSYSTEM_HOST,
|
|
11
|
+
): string {
|
|
12
|
+
if (!localeSlug || !VALID_SLUGS.has(localeSlug)) return href;
|
|
13
|
+
|
|
14
|
+
let url: URL;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(href);
|
|
17
|
+
} catch {
|
|
18
|
+
return href;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (url.hostname !== ecosystemHost) return href;
|
|
22
|
+
|
|
23
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
24
|
+
if (segments.length === 0) return href;
|
|
25
|
+
|
|
26
|
+
if (segments.length >= 2 && VALID_SLUGS.has(segments[1])) {
|
|
27
|
+
return href;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
segments.splice(1, 0, localeSlug);
|
|
31
|
+
url.pathname = `/${segments.join('/')}/`;
|
|
32
|
+
|
|
33
|
+
return url.toString();
|
|
34
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import type { IconSetData } from '../types/icon';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns true if the SVG body contains explicit color fills (hex, CSS vars,
|
|
8
|
+
* gradient refs, named colors) — i.e. anything other than "none" or "currentColor".
|
|
9
|
+
*/
|
|
10
|
+
export function hasExplicitColors(body: string): boolean {
|
|
11
|
+
const fills = body.match(/fill="([^"]*)"/g) || [];
|
|
12
|
+
return fills.some((f) => f !== 'fill="none"' && f !== 'fill="currentColor"');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves a `prefix:name` icon identifier to a complete SVG string
|
|
17
|
+
* using the installed Iconify JSON packages. Designed for synchronous
|
|
18
|
+
* use at module scope (e.g. in config.ts default values).
|
|
19
|
+
*/
|
|
20
|
+
export function resolveIcon(name: string): string {
|
|
21
|
+
const colonIndex = name.indexOf(':');
|
|
22
|
+
if (colonIndex === -1) {
|
|
23
|
+
throw new Error(`Invalid icon name "${name}". Expected "prefix:name" format.`);
|
|
24
|
+
}
|
|
25
|
+
const prefix = name.slice(0, colonIndex);
|
|
26
|
+
const iconName = name.slice(colonIndex + 1);
|
|
27
|
+
|
|
28
|
+
let iconData: IconSetData;
|
|
29
|
+
switch (prefix) {
|
|
30
|
+
case 'lucide':
|
|
31
|
+
iconData = require('@iconify-json/lucide/icons.json');
|
|
32
|
+
break;
|
|
33
|
+
case 'carbon':
|
|
34
|
+
iconData = require('@iconify-json/carbon/icons.json');
|
|
35
|
+
break;
|
|
36
|
+
case 'mdi':
|
|
37
|
+
iconData = require('@iconify-json/mdi/icons.json');
|
|
38
|
+
break;
|
|
39
|
+
case 'phosphor':
|
|
40
|
+
iconData = require('@iconify-json/ph/icons.json');
|
|
41
|
+
break;
|
|
42
|
+
case 'tabler':
|
|
43
|
+
iconData = require('@iconify-json/tabler/icons.json');
|
|
44
|
+
break;
|
|
45
|
+
case 'f5-brand':
|
|
46
|
+
iconData = require('@f5xc-salesdemos/icons-f5-brand/icons.json');
|
|
47
|
+
break;
|
|
48
|
+
case 'f5xc':
|
|
49
|
+
iconData = require('@f5xc-salesdemos/icons-f5xc/icons.json');
|
|
50
|
+
break;
|
|
51
|
+
case 'hashicorp-flight':
|
|
52
|
+
iconData = require('@f5xc-salesdemos/icons-hashicorp-flight/icons.json');
|
|
53
|
+
break;
|
|
54
|
+
default:
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Unknown icon prefix "${prefix}". Available: lucide, carbon, mdi, phosphor, tabler, f5-brand, f5xc, hashicorp-flight`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const icon = iconData.icons?.[iconName];
|
|
61
|
+
if (!icon) {
|
|
62
|
+
throw new Error(`Icon "${iconName}" not found in "${prefix}" icon set.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const w = icon.width ?? iconData.width ?? 24;
|
|
66
|
+
const h = icon.height ?? iconData.height ?? 24;
|
|
67
|
+
const isPalette = iconData.info?.palette === true || hasExplicitColors(icon.body);
|
|
68
|
+
const fillAttr = isPalette ? '' : ' fill="currentColor"';
|
|
69
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 ${w} ${h}"${fillAttr}>${icon.body}</svg>`;
|
|
70
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { filePathToSlug } from './subcategory-sidebar';
|
|
3
|
+
|
|
4
|
+
describe('filePathToSlug', () => {
|
|
5
|
+
it('lowercases capitalised path segments to match Starlight entry slugs', () => {
|
|
6
|
+
// Regression: a capitalised directory (e.g. "Enhancements/") previously
|
|
7
|
+
// produced a sidebar slug that matched no (lowercased) content entry,
|
|
8
|
+
// failing the Starlight build with "slug does not exist".
|
|
9
|
+
expect(filePathToSlug('Enhancements/healthcheck-enhancements.mdx')).toBe('/enhancements/healthcheck-enhancements/');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('leaves already-lowercase paths unchanged', () => {
|
|
13
|
+
expect(filePathToSlug('resources/api_crawler.md')).toBe('/resources/api_crawler/');
|
|
14
|
+
expect(filePathToSlug('guides/getting-started.md')).toBe('/guides/getting-started/');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('maps index files to the root slug', () => {
|
|
18
|
+
expect(filePathToSlug('index.md')).toBe('/');
|
|
19
|
+
expect(filePathToSlug('section/index.mdx')).toBe('/section/');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('normalises backslashes to forward slashes', () => {
|
|
23
|
+
expect(filePathToSlug('Enhancements\\foo.mdx')).toBe('/enhancements/foo/');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { sidebarTranslations } from '../i18n/translations.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Starlight sidebar config types (simplified).
|
|
8
|
+
*/
|
|
9
|
+
type SidebarLink = { label: string; link?: string; slug?: string; translations?: Record<string, string> };
|
|
10
|
+
type SidebarGroup = { label: string; items: SidebarItem[]; collapsed?: boolean; translations?: Record<string, string> };
|
|
11
|
+
type SidebarItem = SidebarLink | SidebarGroup;
|
|
12
|
+
|
|
13
|
+
type DocType = 'resource' | 'data-source' | 'guide' | 'function';
|
|
14
|
+
|
|
15
|
+
interface DocEntry {
|
|
16
|
+
title: string;
|
|
17
|
+
subcategory: string | undefined;
|
|
18
|
+
docType: DocType;
|
|
19
|
+
slug: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Detect the doc type from a file path relative to the content directory.
|
|
24
|
+
* Matches the first path segment against known prefixes.
|
|
25
|
+
*/
|
|
26
|
+
function detectDocType(relativePath: string): DocType | undefined {
|
|
27
|
+
const normalized = relativePath.replace(/\\/g, '/');
|
|
28
|
+
if (normalized.startsWith('resources/')) return 'resource';
|
|
29
|
+
if (normalized.startsWith('data-sources/')) return 'data-source';
|
|
30
|
+
if (normalized.startsWith('guides/')) return 'guide';
|
|
31
|
+
if (normalized.startsWith('functions/')) return 'function';
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Recursively collect all .md and .mdx files under a directory.
|
|
37
|
+
*/
|
|
38
|
+
function collectMarkdownFiles(dir: string): string[] {
|
|
39
|
+
const results: string[] = [];
|
|
40
|
+
if (!fs.existsSync(dir)) return results;
|
|
41
|
+
|
|
42
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const fullPath = path.join(dir, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
results.push(...collectMarkdownFiles(fullPath));
|
|
47
|
+
} else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
|
|
48
|
+
results.push(fullPath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Clean a page_title to match Terraform Registry sidebar labels.
|
|
56
|
+
* Resources: "f5xc_http_loadbalancer Resource - terraform-provider-f5xc" → "f5xc_http_loadbalancer"
|
|
57
|
+
* Data Sources: "f5xc_http_loadbalancer Data Source - terraform-provider-f5xc" → "f5xc_http_loadbalancer"
|
|
58
|
+
* Functions: "blindfold function - terraform-provider-f5xc" → "blindfold"
|
|
59
|
+
* Guides: "Guide: HTTP Load Balancer with Security Features" → "HTTP Load Balancer with Security Features"
|
|
60
|
+
*/
|
|
61
|
+
function cleanPageTitle(pageTitle: string, docType: DocType): string {
|
|
62
|
+
if (docType === 'guide') {
|
|
63
|
+
return pageTitle.replace(/^Guide:\s*/i, '').trim();
|
|
64
|
+
}
|
|
65
|
+
if (docType === 'function') {
|
|
66
|
+
return pageTitle.replace(/\s+function\s*-\s*.*$/i, '').trim();
|
|
67
|
+
}
|
|
68
|
+
return pageTitle.replace(/\s+(Resource|Data Source)\s*-\s*.*$/i, '').trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const KNOWN_ACRONYMS = new Set(['api', 'faq', 'dns', 'cli', 'sdk', 'ui', 'ip', 'mcp', 'tui']);
|
|
72
|
+
|
|
73
|
+
function kebabToTitleCase(kebab: string): string {
|
|
74
|
+
return kebab
|
|
75
|
+
.split('-')
|
|
76
|
+
.map((word) => (KNOWN_ACRONYMS.has(word) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1)))
|
|
77
|
+
.join(' ');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Convert a file path relative to the content dir into a Starlight link slug.
|
|
82
|
+
* e.g. "resources/api_crawler.md" → "/resources/api_crawler/"
|
|
83
|
+
* "guides/getting-started.md" → "/guides/getting-started/"
|
|
84
|
+
* "index.md" → "/"
|
|
85
|
+
*/
|
|
86
|
+
export function filePathToSlug(relativePath: string): string {
|
|
87
|
+
const normalized = relativePath
|
|
88
|
+
.replace(/\\/g, '/')
|
|
89
|
+
.replace(/\.mdx?$/, '')
|
|
90
|
+
.replace(/\/index$/, '')
|
|
91
|
+
// Starlight lowercases content-entry slugs, so the sidebar slug must be
|
|
92
|
+
// lowercased too — otherwise a capitalised path segment (e.g. "Enhancements/")
|
|
93
|
+
// produces a sidebar slug that matches no entry and fails the build.
|
|
94
|
+
.toLowerCase();
|
|
95
|
+
|
|
96
|
+
if (normalized === 'index' || normalized === '') return '/';
|
|
97
|
+
return `/${normalized}/`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Scan the content directory for .md/.mdx files, parse frontmatter,
|
|
102
|
+
* and build a Starlight sidebar config grouped by subcategory.
|
|
103
|
+
*
|
|
104
|
+
* Returns `undefined` if no files contain a `subcategory` field,
|
|
105
|
+
* letting Starlight fall back to directory-based auto-generation.
|
|
106
|
+
*/
|
|
107
|
+
export function buildSubcategorySidebar(contentDir: string): SidebarItem[] | undefined {
|
|
108
|
+
const resolvedDir = path.resolve(contentDir);
|
|
109
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
110
|
+
console.warn(`[subcategory-sidebar] Content directory not found: ${resolvedDir}`);
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// If an "en" subdirectory exists, scan it instead (locale-aware content structure)
|
|
115
|
+
const enDir = path.join(resolvedDir, 'en');
|
|
116
|
+
const scanDir = fs.existsSync(enDir) ? enDir : resolvedDir;
|
|
117
|
+
|
|
118
|
+
const files = collectMarkdownFiles(scanDir);
|
|
119
|
+
const docs: DocEntry[] = [];
|
|
120
|
+
let hasAnySubcategory = false;
|
|
121
|
+
let hasOverview = false;
|
|
122
|
+
|
|
123
|
+
for (const filePath of files) {
|
|
124
|
+
const relativePath = path.relative(scanDir, filePath).replace(/\\/g, '/');
|
|
125
|
+
const slug = filePathToSlug(relativePath);
|
|
126
|
+
|
|
127
|
+
// Track overview page separately
|
|
128
|
+
if (slug === '/') {
|
|
129
|
+
hasOverview = true;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Skip index pages — they serve as section headers, not content
|
|
134
|
+
const baseName = path.basename(relativePath, path.extname(relativePath));
|
|
135
|
+
if (baseName === 'index') continue;
|
|
136
|
+
|
|
137
|
+
const docType = detectDocType(relativePath);
|
|
138
|
+
if (!docType) continue; // skip files not in a recognized directory
|
|
139
|
+
|
|
140
|
+
let frontmatter: Record<string, unknown>;
|
|
141
|
+
try {
|
|
142
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
143
|
+
const parsed = matter(raw);
|
|
144
|
+
frontmatter = parsed.data;
|
|
145
|
+
} catch {
|
|
146
|
+
continue; // skip unparseable files
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Determine title from page_title (or title after entrypoint.sh renames it),
|
|
150
|
+
// then clean it to match Terraform Registry sidebar label format.
|
|
151
|
+
let title = '';
|
|
152
|
+
if (typeof frontmatter.title === 'string' && frontmatter.title.trim()) {
|
|
153
|
+
title = frontmatter.title.trim();
|
|
154
|
+
} else if (typeof frontmatter.page_title === 'string' && frontmatter.page_title.trim()) {
|
|
155
|
+
title = frontmatter.page_title.trim();
|
|
156
|
+
} else {
|
|
157
|
+
title = path.basename(filePath, path.extname(filePath)).replace(/[-_]/g, ' ');
|
|
158
|
+
}
|
|
159
|
+
title = cleanPageTitle(title, docType);
|
|
160
|
+
|
|
161
|
+
const subcategory =
|
|
162
|
+
typeof frontmatter.subcategory === 'string' && frontmatter.subcategory.trim()
|
|
163
|
+
? frontmatter.subcategory.trim()
|
|
164
|
+
: undefined;
|
|
165
|
+
|
|
166
|
+
if (subcategory) hasAnySubcategory = true;
|
|
167
|
+
|
|
168
|
+
docs.push({ title, subcategory, docType, slug });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// If no files have subcategory, generate autogenerate entries with translations
|
|
172
|
+
if (!hasAnySubcategory) {
|
|
173
|
+
const topLevelDirs: string[] = [];
|
|
174
|
+
try {
|
|
175
|
+
const dirEntries = fs.readdirSync(scanDir, { withFileTypes: true });
|
|
176
|
+
for (const entry of dirEntries) {
|
|
177
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && !entry.name.startsWith('_')) {
|
|
178
|
+
topLevelDirs.push(entry.name);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
if (topLevelDirs.length === 0) return undefined;
|
|
185
|
+
|
|
186
|
+
const sidebar: SidebarItem[] = [];
|
|
187
|
+
|
|
188
|
+
if (hasOverview) {
|
|
189
|
+
sidebar.push({ label: 'Overview', link: '/', translations: sidebarTranslations.Overview });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const dir of topLevelDirs.sort()) {
|
|
193
|
+
const label = kebabToTitleCase(dir);
|
|
194
|
+
const translations = sidebarTranslations[label as keyof typeof sidebarTranslations];
|
|
195
|
+
const dirPath = path.join(scanDir, dir);
|
|
196
|
+
const dirFiles = collectMarkdownFiles(dirPath);
|
|
197
|
+
const links: SidebarLink[] = dirFiles
|
|
198
|
+
.map((filePath) => {
|
|
199
|
+
const relativePath = path.relative(scanDir, filePath).replace(/\\/g, '/');
|
|
200
|
+
const slug = filePathToSlug(relativePath);
|
|
201
|
+
const baseName = path.basename(relativePath, path.extname(relativePath));
|
|
202
|
+
if (baseName === 'index') return null;
|
|
203
|
+
return { slug: slug.replace(/^\/|\/$/g, '') } as SidebarLink;
|
|
204
|
+
})
|
|
205
|
+
.filter((item): item is SidebarLink => item !== null);
|
|
206
|
+
|
|
207
|
+
if (links.length > 0) {
|
|
208
|
+
sidebar.push({
|
|
209
|
+
label,
|
|
210
|
+
collapsed: false,
|
|
211
|
+
...(translations ? { translations } : {}),
|
|
212
|
+
items: links,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return sidebar;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Partition docs by type
|
|
221
|
+
const guides = docs.filter((d) => d.docType === 'guide');
|
|
222
|
+
const functions = docs.filter((d) => d.docType === 'function');
|
|
223
|
+
const resources = docs.filter((d) => d.docType === 'resource');
|
|
224
|
+
const dataSources = docs.filter((d) => d.docType === 'data-source');
|
|
225
|
+
|
|
226
|
+
// Alphabetical sort helper
|
|
227
|
+
const byTitle = (a: DocEntry, b: DocEntry) => a.title.localeCompare(b.title);
|
|
228
|
+
|
|
229
|
+
// Build sidebar
|
|
230
|
+
const sidebar: SidebarItem[] = [];
|
|
231
|
+
|
|
232
|
+
// 1. Overview link
|
|
233
|
+
if (hasOverview) {
|
|
234
|
+
sidebar.push({ label: 'Overview', link: '/', translations: sidebarTranslations.Overview });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 2. Guides group
|
|
238
|
+
if (guides.length > 0) {
|
|
239
|
+
sidebar.push({
|
|
240
|
+
label: 'Guides',
|
|
241
|
+
collapsed: true,
|
|
242
|
+
translations: sidebarTranslations.Guides,
|
|
243
|
+
items: guides.sort(byTitle).map((g) => ({ slug: g.slug.replace(/^\/|\/$/g, '') })),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 3. Functions group
|
|
248
|
+
if (functions.length > 0) {
|
|
249
|
+
sidebar.push({
|
|
250
|
+
label: 'Functions',
|
|
251
|
+
collapsed: true,
|
|
252
|
+
translations: sidebarTranslations.Functions,
|
|
253
|
+
items: functions.sort(byTitle).map((f) => ({ slug: f.slug.replace(/^\/|\/$/g, '') })),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 4. Subcategory groups (resources + data sources)
|
|
258
|
+
const subcategoryMap = new Map<string, { resources: DocEntry[]; dataSources: DocEntry[] }>();
|
|
259
|
+
|
|
260
|
+
for (const doc of [...resources, ...dataSources]) {
|
|
261
|
+
const cat = doc.subcategory ?? 'Uncategorized';
|
|
262
|
+
let bucket = subcategoryMap.get(cat);
|
|
263
|
+
if (!bucket) {
|
|
264
|
+
bucket = { resources: [], dataSources: [] };
|
|
265
|
+
subcategoryMap.set(cat, bucket);
|
|
266
|
+
}
|
|
267
|
+
if (doc.docType === 'resource') {
|
|
268
|
+
bucket.resources.push(doc);
|
|
269
|
+
} else {
|
|
270
|
+
bucket.dataSources.push(doc);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Sort subcategories alphabetically, but push "Uncategorized" to the end
|
|
275
|
+
const sortedCategories = [...subcategoryMap.keys()].sort((a, b) => {
|
|
276
|
+
if (a === 'Uncategorized') return 1;
|
|
277
|
+
if (b === 'Uncategorized') return -1;
|
|
278
|
+
return a.localeCompare(b);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
for (const category of sortedCategories) {
|
|
282
|
+
const entry = subcategoryMap.get(category);
|
|
283
|
+
if (!entry) continue;
|
|
284
|
+
const { resources: catResources, dataSources: catDataSources } = entry;
|
|
285
|
+
const groupItems: SidebarItem[] = [];
|
|
286
|
+
|
|
287
|
+
if (catResources.length > 0) {
|
|
288
|
+
groupItems.push({
|
|
289
|
+
label: 'Resources',
|
|
290
|
+
translations: sidebarTranslations.Resources,
|
|
291
|
+
items: catResources.sort(byTitle).map((r) => ({ slug: r.slug.replace(/^\/|\/$/g, '') })),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (catDataSources.length > 0) {
|
|
296
|
+
groupItems.push({
|
|
297
|
+
label: 'Data Sources',
|
|
298
|
+
translations: sidebarTranslations['Data Sources'],
|
|
299
|
+
items: catDataSources.sort(byTitle).map((d) => ({ slug: d.slug.replace(/^\/|\/$/g, '') })),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (groupItems.length > 0) {
|
|
304
|
+
sidebar.push({
|
|
305
|
+
label: category,
|
|
306
|
+
collapsed: true,
|
|
307
|
+
...(sidebarTranslations[category as keyof typeof sidebarTranslations]
|
|
308
|
+
? { translations: sidebarTranslations[category as keyof typeof sidebarTranslations] }
|
|
309
|
+
: {}),
|
|
310
|
+
items: groupItems,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return sidebar;
|
|
316
|
+
}
|