@aravindc26/velu 0.11.1 → 0.11.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aravindc26/velu",
3
- "version": "0.11.1",
3
+ "version": "0.11.4",
4
4
  "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolve, join, dirname, delimiter } from "node:path";
2
- import { existsSync, mkdirSync, writeFileSync, readdirSync, copyFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, copyFileSync, cpSync, rmSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
@@ -195,13 +195,25 @@ async function paths(docsDir: string) {
195
195
 
196
196
  async function generateProject(docsDir: string): Promise<string> {
197
197
  const { build } = await import("./build.js");
198
- // Place .velu-out inside CLI package dir so node_modules resolves
199
- // naturally by walking up — avoids symlinks that Turbopack rejects.
200
- const outDir = join(PACKAGE_ROOT, ".velu-out");
198
+ // Generate into the active docs project directory.
199
+ const outDir = join(docsDir, ".velu-out");
201
200
  build(docsDir, outDir);
202
201
  return outDir;
203
202
  }
204
203
 
204
+ function samePath(a: string, b: string): boolean {
205
+ return resolve(a).replace(/\\/g, "/").toLowerCase() === resolve(b).replace(/\\/g, "/").toLowerCase();
206
+ }
207
+
208
+ function prepareRuntimeOutDir(docsOutDir: string): string {
209
+ const runtimeOutDir = join(PACKAGE_ROOT, ".velu-out");
210
+ if (samePath(docsOutDir, runtimeOutDir)) return runtimeOutDir;
211
+
212
+ rmSync(runtimeOutDir, { recursive: true, force: true });
213
+ cpSync(docsOutDir, runtimeOutDir, { recursive: true, force: true });
214
+ return runtimeOutDir;
215
+ }
216
+
205
217
  async function buildStatic(outDir: string, docsDir: string) {
206
218
  await new Promise<void>((res, rej) => {
207
219
  const child = spawn("node", ["_server.mjs", "build"], {
@@ -245,10 +257,19 @@ function exportMarkdownRoutes(outDir: string) {
245
257
  }
246
258
 
247
259
  async function buildSite(docsDir: string) {
248
- const outDir = await generateProject(docsDir);
249
- await buildStatic(outDir, docsDir);
250
- exportMarkdownRoutes(outDir);
251
- const staticOutDir = join(outDir, "dist");
260
+ const docsOutDir = await generateProject(docsDir);
261
+ const runtimeOutDir = prepareRuntimeOutDir(docsOutDir);
262
+ await buildStatic(runtimeOutDir, docsDir);
263
+ exportMarkdownRoutes(runtimeOutDir);
264
+
265
+ if (!samePath(docsOutDir, runtimeOutDir)) {
266
+ const docsDistDir = join(docsOutDir, "dist");
267
+ const runtimeDistDir = join(runtimeOutDir, "dist");
268
+ rmSync(docsDistDir, { recursive: true, force: true });
269
+ cpSync(runtimeDistDir, docsDistDir, { recursive: true, force: true });
270
+ }
271
+
272
+ const staticOutDir = join(docsOutDir, "dist");
252
273
  console.log(`\nšŸ“ Static site output: ${staticOutDir}`);
253
274
  }
254
275
 
@@ -269,8 +290,9 @@ function spawnServer(outDir: string, command: string, port: number, docsDir: str
269
290
  }
270
291
 
271
292
  async function run(docsDir: string, port: number) {
272
- const outDir = await generateProject(docsDir);
273
- spawnServer(outDir, "dev", port, docsDir);
293
+ const docsOutDir = await generateProject(docsDir);
294
+ const runtimeOutDir = prepareRuntimeOutDir(docsOutDir);
295
+ spawnServer(runtimeOutDir, "dev", port, docsDir);
274
296
  }
275
297
 
276
298
  // ── Parse args ───────────────────────────────────────────────────────────────────
@@ -151,14 +151,19 @@ function buildNavbarTabs(tree: unknown): Array<{
151
151
  ? (tree as { children: unknown[] }).children
152
152
  : [];
153
153
 
154
- const rootFolder = rootChildren.find((child) => {
154
+ const rootFolders = rootChildren.filter((child) => {
155
155
  const node = child as PageTreeFolderNode;
156
156
  return node?.type === 'folder' && node.root === true;
157
- }) as PageTreeFolderNode | undefined;
157
+ }) as PageTreeFolderNode[];
158
158
 
159
- const tabFolders = Array.isArray(rootFolder?.children)
160
- ? rootFolder!.children.filter((child) => (child as PageTreeFolderNode)?.type === 'folder') as PageTreeFolderNode[]
161
- : rootChildren.filter((child) => (child as PageTreeFolderNode)?.type === 'folder') as PageTreeFolderNode[];
159
+ // Two shapes are supported:
160
+ // 1) Multiple root folders => each root folder is a top-level tab.
161
+ // 2) Single root folder containing tab folders as children.
162
+ const tabFolders: PageTreeFolderNode[] = rootFolders.length > 1
163
+ ? rootFolders
164
+ : (rootFolders.length === 1 && Array.isArray(rootFolders[0]?.children)
165
+ ? rootFolders[0].children.filter((child) => (child as PageTreeFolderNode)?.type === 'folder') as PageTreeFolderNode[]
166
+ : rootChildren.filter((child) => (child as PageTreeFolderNode)?.type === 'folder') as PageTreeFolderNode[]);
162
167
 
163
168
  const tabs = tabFolders
164
169
  .map((folder) => {
@@ -173,13 +178,7 @@ function buildNavbarTabs(tree: unknown): Array<{
173
178
  urls,
174
179
  };
175
180
  })
176
- .filter((tab): tab is {
177
- url: string;
178
- title: ReactNode;
179
- icon?: ReactNode;
180
- description?: ReactNode;
181
- urls: Set<string>;
182
- } => tab !== null);
181
+ .filter((tab): tab is NonNullable<typeof tab> => tab !== null);
183
182
 
184
183
  return tabs.length > 0 ? tabs : undefined;
185
184
  }
@@ -257,6 +256,30 @@ function scopeTreeToTab<T extends { children?: unknown[] }>(
257
256
  if (!normalizedTab) return tree;
258
257
 
259
258
  const topChildren = Array.isArray(tree.children) ? tree.children : [];
259
+ const rootFolders = topChildren.filter((child) => {
260
+ const node = child as PageTreeFolderNode;
261
+ return node?.type === 'folder' && node.root === true;
262
+ }) as PageTreeFolderNode[];
263
+
264
+ // When docs have multiple top-level root folders (tabs), avoid rendering
265
+ // the sidebar root switcher. Show only the active tab's children.
266
+ if (rootFolders.length > 1) {
267
+ const activeTopTab = (containerSlug ?? tabSlug ?? '').trim().toLowerCase();
268
+ if (!activeTopTab) return tree;
269
+
270
+ const matchedRoot = rootFolders.find((folder) => {
271
+ const urls = collectFolderUrls(folder);
272
+ for (const url of urls) {
273
+ const segments = url.split('/').filter(Boolean).map((segment) => segment.toLowerCase());
274
+ if ((segments[0] ?? '') === activeTopTab) return true;
275
+ }
276
+ return false;
277
+ });
278
+
279
+ if (!matchedRoot || !Array.isArray(matchedRoot.children)) return tree;
280
+ return { ...tree, children: matchedRoot.children } as T;
281
+ }
282
+
260
283
  const rootFolder = topChildren.find((child) => {
261
284
  const node = child as PageTreeFolderNode;
262
285
  return node?.type === 'folder' && node.root === true;
@@ -265,7 +288,7 @@ function scopeTreeToTab<T extends { children?: unknown[] }>(
265
288
  if (!rootFolder || !Array.isArray(rootFolder.children)) return tree;
266
289
 
267
290
  const normalizedContainer = (containerSlug ?? '').trim().toLowerCase();
268
- const matchingChildren = rootFolder.children.filter((child) => {
291
+ const matchingChildren = rootFolder.children.filter((child): child is PageTreeFolderNode => {
269
292
  const folder = child as PageTreeFolderNode;
270
293
  if (folder?.type !== 'folder') return false;
271
294
 
@@ -303,7 +326,6 @@ export default async function SlugLayout({ children, params }: SlugLayoutProps)
303
326
  const currentVersion = resolveCurrentVersion(resolvedParams.slug, versions);
304
327
  const currentProduct = resolveCurrentProduct(resolvedParams.slug, products);
305
328
  const { containerSlug, tabSlug: currentTabSlug } = resolveTabContext(resolvedParams.slug);
306
-
307
329
  const activePrefix = currentVersion?.slug ?? currentProduct?.slug;
308
330
  const containerScopedTree = filterTreeBySlugPrefix(source.getPageTree(locale), activePrefix);
309
331
  const rawTree = scopeTreeToTab(containerScopedTree, currentTabSlug, containerSlug);
@@ -556,9 +556,10 @@ export default async function Page({ params }: PageProps) {
556
556
 
557
557
  if (!page) notFound();
558
558
 
559
- const MDX = page.data.body;
560
- const sourceMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
561
559
  const pageDataRecord = (page.data as unknown) as Record<string, unknown>;
560
+ const MDX = pageDataRecord.body as any;
561
+ if (typeof MDX !== 'function') notFound();
562
+ const sourceMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
562
563
  const dataMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
563
564
  ? String(pageDataRecord.processedMarkdown)
564
565
  : undefined;
@@ -589,8 +590,14 @@ export default async function Page({ params }: PageProps) {
589
590
  const playgroundDisplay = normalizePlaygroundDisplay(frontmatter.playground, apiConfig.playgroundDisplay);
590
591
  const proxyUrl = apiConfig.playgroundProxyEnabled ? '/api/proxy' : '';
591
592
  const authMethod = normalizeAuthMethod(frontmatter.authMethod, apiConfig.authMethod);
593
+ const inlineApiTitle = typeof pageDataRecord.title === 'string' && pageDataRecord.title.trim().length > 0
594
+ ? pageDataRecord.title
595
+ : (frontmatter.title ?? pageSlug);
596
+ const inlineApiDescription = typeof pageDataRecord.description === 'string'
597
+ ? pageDataRecord.description
598
+ : frontmatter.description;
592
599
  const inlineApiDoc = parsedApiFrontmatter
593
- ? buildInlineApiDoc(parsedApiFrontmatter, page.data.title, page.data.description, authMethod, apiConfig.authName)
600
+ ? buildInlineApiDoc(parsedApiFrontmatter, inlineApiTitle, inlineApiDescription, authMethod, apiConfig.authName)
594
601
  : null;
595
602
  const hasPanelExamples = typeof effectiveMarkdown === 'string'
596
603
  && /<(?:Panel|RequestExample|ResponseExample)(?:\s|>)/.test(effectiveMarkdown);
@@ -616,7 +623,9 @@ export default async function Page({ params }: PageProps) {
616
623
  <div id="velu-api-toc-rail-host" />
617
624
  </div>
618
625
  ) : undefined;
619
- const toc = hasChangelog ? parsedChangelog.toc : page.data.toc;
626
+ const pageToc = pageDataRecord.toc as any;
627
+ const pageFull = typeof pageDataRecord.full === 'boolean' ? pageDataRecord.full : undefined;
628
+ const toc = hasChangelog ? parsedChangelog.toc : pageToc;
620
629
  const tableOfContentHeader = apiTocHeader ?? (hasPanelExamples ? <div className="velu-toc-panel-rail" /> : undefined);
621
630
  const orderedPages = hasI18n ? source.getPages(locale) : source.getPages();
622
631
  const currentPageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
@@ -643,12 +652,12 @@ export default async function Page({ params }: PageProps) {
643
652
  }
644
653
 
645
654
  return (
646
- <DocsPage
647
- toc={toc}
648
- full={hasChangelog ? false : (hasApiTocRail ? false : page.data.full)}
649
- tableOfContent={tableOfContentHeader ? { header: tableOfContentHeader } : undefined}
650
- footer={{ enabled: false }}
651
- >
655
+ <DocsPage
656
+ toc={toc}
657
+ full={hasChangelog ? false : (hasApiTocRail ? false : pageFull)}
658
+ tableOfContent={tableOfContentHeader ? { header: tableOfContentHeader } : undefined}
659
+ footer={{ enabled: false }}
660
+ >
652
661
  <div
653
662
  data-pagefind-body
654
663
  data-pagefind-meta={metaAttrs.join(',')}
@@ -836,17 +845,17 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
836
845
  height: Number(ogImageHeight),
837
846
  }));
838
847
  const twitterImages = twitterImagesRaw.map((entry) => toAbsoluteMetaUrl(siteOrigin, entry));
839
- const openGraph: NonNullable<Metadata['openGraph']> = {
840
- type: (mergedMetatags['og:type'] as NonNullable<Metadata['openGraph']>['type']) || 'website',
848
+ const openGraph: Metadata['openGraph'] = {
849
+ type: (mergedMetatags['og:type'] as any) || 'website',
841
850
  siteName: mergedMetatags['og:site_name'] || siteName,
842
851
  title: mergedMetatags['og:title'] || resolvedTitle,
843
852
  ...(resolvedDescription ? { description: mergedMetatags['og:description'] || resolvedDescription } : {}),
844
853
  url: mergedMetatags['og:url'] ? toAbsoluteMetaUrl(siteOrigin, mergedMetatags['og:url']) : canonical,
845
854
  ...(mergedMetatags['og:locale'] ? { locale: mergedMetatags['og:locale'] } : {}),
846
- ...(openGraphImages.length > 0 ? { images: openGraphImages as NonNullable<Metadata['openGraph']>['images'] } : {}),
855
+ ...(openGraphImages.length > 0 ? { images: openGraphImages as any } : {}),
847
856
  };
848
- const twitter: NonNullable<Metadata['twitter']> = {
849
- card: (mergedMetatags['twitter:card'] as NonNullable<Metadata['twitter']>['card']) || 'summary_large_image',
857
+ const twitter: Metadata['twitter'] = {
858
+ card: (mergedMetatags['twitter:card'] as any) || 'summary_large_image',
850
859
  title: mergedMetatags['twitter:title'] || resolvedTitle,
851
860
  ...(resolvedDescription ? { description: mergedMetatags['twitter:description'] || resolvedDescription } : {}),
852
861
  ...(mergedMetatags['twitter:site'] ? { site: mergedMetatags['twitter:site'] } : {}),
@@ -602,8 +602,8 @@ nextjs-portal {
602
602
  .velu-page-feedback-row {
603
603
  display: flex;
604
604
  align-items: center;
605
- justify-content: space-between;
606
- gap: 0.75rem;
605
+ justify-content: flex-start;
606
+ gap: 0.55rem;
607
607
  flex-wrap: wrap;
608
608
  }
609
609
 
@@ -619,6 +619,7 @@ nextjs-portal {
619
619
  display: inline-flex;
620
620
  align-items: center;
621
621
  gap: 0.5rem;
622
+ margin-left: 0.1rem;
622
623
  }
623
624
 
624
625
  .velu-page-feedback-btn {
@@ -3,8 +3,8 @@ import {
3
3
  getSiteTitle,
4
4
  normalizePath,
5
5
  readCustomLlmsFile,
6
- resolveRequestOrigin,
7
6
  } from '@/lib/llms';
7
+ import { getSiteOrigin } from '@/lib/velu';
8
8
 
9
9
  export const dynamic = 'force-static';
10
10
 
@@ -24,7 +24,7 @@ function toSpecUrl(origin: string, spec: string): string {
24
24
  return `${origin}${normalizePath(trimmed)}`;
25
25
  }
26
26
 
27
- export async function GET(request: Request) {
27
+ export async function GET() {
28
28
  const custom = await readCustomLlmsFile('llms.txt');
29
29
  if (custom !== null) {
30
30
  return new Response(custom, {
@@ -38,7 +38,7 @@ export async function GET(request: Request) {
38
38
  }
39
39
 
40
40
  const siteTitle = getSiteTitle();
41
- const origin = resolveRequestOrigin(request);
41
+ const origin = getSiteOrigin();
42
42
  const pages = await collectLlmsPages();
43
43
  const docsPages = pages.filter((page) => !page.noindex && !(page.sourceKind === 'generated' && page.isOpenApiOperation));
44
44
  const openApiSpecs = Array.from(
@@ -3,12 +3,12 @@ import {
3
3
  getSiteTitle,
4
4
  normalizePath,
5
5
  readCustomLlmsFile,
6
- resolveRequestOrigin,
7
6
  } from '@/lib/llms';
7
+ import { getSiteOrigin } from '@/lib/velu';
8
8
 
9
9
  export const dynamic = 'force-static';
10
10
 
11
- export async function GET(request: Request) {
11
+ export async function GET() {
12
12
  const custom = await readCustomLlmsFile('llms-full.txt');
13
13
  if (custom !== null) {
14
14
  return new Response(custom, {
@@ -22,7 +22,7 @@ export async function GET(request: Request) {
22
22
  }
23
23
 
24
24
  const siteTitle = getSiteTitle();
25
- const origin = resolveRequestOrigin(request);
25
+ const origin = getSiteOrigin();
26
26
  const pages = await collectLlmsPages({ includeMarkdown: true });
27
27
  const includedPages = pages.filter((page) => {
28
28
  if (page.noindex) return false;
@@ -3,6 +3,8 @@ import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { getSeoConfig, getSiteOrigin } from '@/lib/velu';
5
5
 
6
+ export const dynamic = 'force-static';
7
+
6
8
  async function readCustomRobotsFile(): Promise<string | null> {
7
9
  const docsDir = process.env.VELU_DOCS_DIR?.trim();
8
10
  if (docsDir) {
@@ -6,7 +6,7 @@ import {
6
6
  parseChangelogFromMarkdown,
7
7
  parseFrontmatterValue,
8
8
  } from '@/lib/changelog';
9
- import { getLanguages } from '@/lib/velu';
9
+ import { getLanguages, getSiteOrigin } from '@/lib/velu';
10
10
 
11
11
  interface RouteParams {
12
12
  slug?: string[];
@@ -85,7 +85,7 @@ export async function generateStaticParams() {
85
85
  return out;
86
86
  }
87
87
 
88
- export async function GET(request: Request, { params }: { params: Promise<RouteParams> }) {
88
+ export async function GET(_request: Request, { params }: { params: Promise<RouteParams> }) {
89
89
  const resolvedParams = await params;
90
90
  const fullSlug = resolvedParams.slug ?? [];
91
91
 
@@ -120,14 +120,7 @@ export async function GET(request: Request, { params }: { params: Promise<RouteP
120
120
  });
121
121
  }
122
122
 
123
- const requestUrl = new URL(request.url);
124
- const forwardedHost = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
125
- const forwardedProto = request.headers.get('x-forwarded-proto') ?? requestUrl.protocol.replace(':', '');
126
- const devPort = process.env.PORT?.trim();
127
- const fallbackOrigin = (requestUrl.hostname === 'localhost' && requestUrl.port === '3000' && devPort)
128
- ? `${requestUrl.protocol}//${requestUrl.hostname}:${devPort}`
129
- : requestUrl.origin;
130
- const origin = forwardedHost ? `${forwardedProto}://${forwardedHost}` : fallbackOrigin;
123
+ const origin = getSiteOrigin();
131
124
  const pagePath = normalizePath(fullSlug.join('/'));
132
125
  const pageUrl = `${origin}${pagePath}`;
133
126
  const rssUrl = `${pageUrl.replace(/\/$/, '')}/rss.xml`;
@@ -4,6 +4,8 @@ import { join } from 'node:path';
4
4
  import { collectLlmsPages, normalizePath } from '@/lib/llms';
5
5
  import { getSeoConfig, getSiteOrigin } from '@/lib/velu';
6
6
 
7
+ export const dynamic = 'force-static';
8
+
7
9
  function escapeXml(value: string): string {
8
10
  return value
9
11
  .replace(/&/g, '&amp;')
@@ -766,26 +766,26 @@ function collectResponseHeaderFields(response: OpenApiRecord): NormalizedField[]
766
766
  const headers = toRecord(response.headers);
767
767
  if (!headers) return [];
768
768
 
769
- return Object.entries(headers)
770
- .map(([headerName, rawHeader]) => {
771
- const headerObject = toRecord(rawHeader);
772
- if (!headerObject) return null;
773
-
774
- const schema = headerObjectSchema(headerObject);
775
- const description = typeof headerObject.description === 'string'
776
- ? headerObject.description
777
- : (typeof schema?.description === 'string' ? schema.description : undefined);
778
-
779
- return {
780
- name: headerName,
781
- type: resolveSchemaType(schema),
782
- required: Boolean(headerObject.required),
783
- deprecated: Boolean(headerObject.deprecated ?? schema?.deprecated),
784
- defaultValue: stringifyDefaultValue(schema?.default),
785
- description,
786
- } satisfies NormalizedField;
787
- })
788
- .filter((field): field is NormalizedField => Boolean(field));
769
+ const fields: NormalizedField[] = [];
770
+ for (const [headerName, rawHeader] of Object.entries(headers)) {
771
+ const headerObject = toRecord(rawHeader);
772
+ if (!headerObject) continue;
773
+
774
+ const schema = headerObjectSchema(headerObject);
775
+ const description = typeof headerObject.description === 'string'
776
+ ? headerObject.description
777
+ : (typeof schema?.description === 'string' ? schema.description : undefined);
778
+
779
+ fields.push({
780
+ name: headerName,
781
+ type: resolveSchemaType(schema),
782
+ required: Boolean(headerObject.required),
783
+ deprecated: Boolean(headerObject.deprecated ?? schema?.deprecated),
784
+ defaultValue: stringifyDefaultValue(schema?.default),
785
+ description,
786
+ } satisfies NormalizedField);
787
+ }
788
+ return fields;
789
789
  }
790
790
 
791
791
  function renderAuthorizationSection(method: MethodInformation, ctx: RenderContext): ReactNode {
@@ -1591,6 +1591,11 @@ export async function VeluOpenAPI({
1591
1591
  && normalizeWebhookName(resolvedTarget.item.name) !== normalizeWebhookName(endpointPath))
1592
1592
  ),
1593
1593
  );
1594
+ const fallbackTargetLabel = resolvedTarget
1595
+ ? (resolvedTarget.type === 'operation'
1596
+ ? `${resolvedTarget.item.method.toUpperCase()} ${resolvedTarget.item.path}`
1597
+ : `WEBHOOK ${resolvedTarget.item.name}`)
1598
+ : '';
1594
1599
 
1595
1600
  return (
1596
1601
  <section className={className}>
@@ -1598,9 +1603,7 @@ export async function VeluOpenAPI({
1598
1603
  <div className="velu-openapi-warning">
1599
1604
  <p>
1600
1605
  Could not find <code>{endpointMethod.toUpperCase()} {endpointPath}</code> in <code>{inlineDocument ? inlineDocumentId : schemaSource}</code>.
1601
- Showing <code>{resolvedTarget!.type === 'operation'
1602
- ? `${resolvedTarget.item.method.toUpperCase()} ${resolvedTarget.item.path}`
1603
- : `WEBHOOK ${resolvedTarget.item.name}`}</code> instead.
1606
+ Showing <code>{fallbackTargetLabel}</code> instead.
1604
1607
  </p>
1605
1608
  </div>
1606
1609
  ) : null}
@@ -99,9 +99,9 @@ function openApiSidebarMethodBadgePlugin() {
99
99
 
100
100
  export const source = loader({
101
101
  baseUrl: '/',
102
- source: docsCollection.toFumadocsSource(),
102
+ source: docsCollection.toFumadocsSource() as any,
103
103
  plugins: [
104
- openApiSidebarMethodBadgePlugin(),
104
+ openApiSidebarMethodBadgePlugin() as any,
105
105
  statusBadgesPlugin({
106
106
  renderBadge: (status: string) => {
107
107
  const normalized = status.trim().toLowerCase();