@aravindc26/velu 0.11.4 → 0.11.6

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.4",
3
+ "version": "0.11.6",
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, cpSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, copyFileSync, cpSync, rmSync, readFileSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
@@ -61,6 +61,11 @@ function init(targetDir: string) {
61
61
  const config = {
62
62
  $schema: "https://raw.githubusercontent.com/aravindc26/velu/main/schema/velu.schema.json",
63
63
  theme: "neutral" as const,
64
+ colors: {
65
+ primary: "#DC143C",
66
+ light: "#DC143C",
67
+ dark: "#DC143C",
68
+ },
64
69
  navigation: {
65
70
  tabs: [
66
71
  {
@@ -256,11 +261,103 @@ function exportMarkdownRoutes(outDir: string) {
256
261
  console.log(`📝 Exported ${copied} markdown files to static route paths`);
257
262
  }
258
263
 
264
+ function collectStaticRoutePaths(distDir: string): string[] {
265
+ const routes = new Set<string>();
266
+
267
+ function walk(relDir: string) {
268
+ const absDir = join(distDir, relDir);
269
+ const entries = readdirSync(absDir, { withFileTypes: true });
270
+
271
+ for (const entry of entries) {
272
+ if (!entry.isDirectory()) continue;
273
+ const childRel = relDir ? join(relDir, entry.name) : entry.name;
274
+ if (existsSync(join(distDir, childRel, "index.html"))) {
275
+ const normalized = childRel.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
276
+ if (
277
+ normalized.length > 0 &&
278
+ !normalized.startsWith("_next") &&
279
+ !normalized.startsWith("_not-found") &&
280
+ normalized !== "404" &&
281
+ !normalized.startsWith("pagefind")
282
+ ) {
283
+ routes.add(`/${normalized}`);
284
+ }
285
+ }
286
+ walk(childRel);
287
+ }
288
+ }
289
+
290
+ walk("");
291
+ return Array.from(routes).sort((a, b) => a.localeCompare(b));
292
+ }
293
+
294
+ function addStaticRouteCompatibility(outDir: string) {
295
+ const distDir = join(outDir, "dist");
296
+ if (!existsSync(distDir)) return;
297
+
298
+ const routes = collectStaticRoutePaths(distDir);
299
+ if (routes.length === 0) return;
300
+
301
+ let aliasCount = 0;
302
+ for (const route of routes) {
303
+ const rel = route.replace(/^\/+/, "");
304
+ const src = join(distDir, rel, "index.html");
305
+ const htmlAlias = join(distDir, `${rel}.html`);
306
+ if (existsSync(src) && !existsSync(htmlAlias)) {
307
+ copyFileSync(src, htmlAlias);
308
+ aliasCount += 1;
309
+ }
310
+ }
311
+
312
+ const fallbackPath = join(distDir, "404.html");
313
+ if (existsSync(fallbackPath)) {
314
+ const html = readFileSync(fallbackPath, "utf-8");
315
+ if (!html.includes("velu-noslash-fallback")) {
316
+ const script = [
317
+ '<script id="velu-noslash-fallback">',
318
+ "(function(){",
319
+ " try {",
320
+ ` var routes = new Set(${JSON.stringify(routes)});`,
321
+ " var path = (window.location && window.location.pathname ? window.location.pathname : '/').replace(/\\/+$/, '');",
322
+ " if (!path || path === '/') return;",
323
+ " if (/\\.[a-zA-Z0-9]+$/.test(path)) return;",
324
+ " if (!routes.has(path)) return;",
325
+ " var search = window.location.search || '';",
326
+ " var hash = window.location.hash || '';",
327
+ " window.location.replace(path + '/' + search + hash);",
328
+ " } catch (_) {}",
329
+ "})();",
330
+ "</script>",
331
+ ].join("");
332
+ const patched = html.includes("</body>") ? html.replace("</body>", `${script}</body>`) : `${html}\n${script}\n`;
333
+ writeFileSync(fallbackPath, patched, "utf-8");
334
+ }
335
+ }
336
+
337
+ const redirectsPath = join(distDir, "_redirects");
338
+ const existingRedirects = existsSync(redirectsPath) ? readFileSync(redirectsPath, "utf-8") : "";
339
+ const existingLines = new Set(existingRedirects.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
340
+ const redirectLines = routes.map((route) => `${route} ${route}/ 301`);
341
+ let redirectAdded = 0;
342
+ for (const line of redirectLines) {
343
+ if (existingLines.has(line)) continue;
344
+ existingLines.add(line);
345
+ redirectAdded += 1;
346
+ }
347
+ if (redirectAdded > 0) {
348
+ const merged = Array.from(existingLines).join("\n") + "\n";
349
+ writeFileSync(redirectsPath, merged, "utf-8");
350
+ }
351
+
352
+ console.log(`🔁 Added static compatibility for ${routes.length} routes (${aliasCount} .html aliases, ${redirectAdded} redirects)`);
353
+ }
354
+
259
355
  async function buildSite(docsDir: string) {
260
356
  const docsOutDir = await generateProject(docsDir);
261
357
  const runtimeOutDir = prepareRuntimeOutDir(docsOutDir);
262
358
  await buildStatic(runtimeOutDir, docsDir);
263
359
  exportMarkdownRoutes(runtimeOutDir);
360
+ addStaticRouteCompatibility(runtimeOutDir);
264
361
 
265
362
  if (!samePath(docsOutDir, runtimeOutDir)) {
266
363
  const docsDistDir = join(docsOutDir, "dist");
@@ -41,6 +41,26 @@ interface PageTreeFolderNode {
41
41
  children?: unknown[];
42
42
  }
43
43
 
44
+ function withTrailingSlashUrl(url: string): string {
45
+ const trimmed = url.trim();
46
+ if (trimmed.length === 0) return trimmed;
47
+ if (/^(https?:|mailto:|tel:|#)/i.test(trimmed)) return trimmed;
48
+
49
+ const hashIndex = trimmed.indexOf('#');
50
+ const queryIndex = trimmed.indexOf('?');
51
+ const endIndex = [hashIndex, queryIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? trimmed.length;
52
+ const path = trimmed.slice(0, endIndex);
53
+ const suffix = trimmed.slice(endIndex);
54
+
55
+ if (!path.startsWith('/')) return trimmed;
56
+ if (path === '/' || path.endsWith('/')) return `${path}${suffix}`;
57
+
58
+ const lastSegment = path.split('/').filter(Boolean).pop() ?? '';
59
+ if (lastSegment.includes('.')) return trimmed;
60
+
61
+ return `${path}/${suffix}`;
62
+ }
63
+
44
64
  function resolveLocale(slugInput: string[] | undefined): string {
45
65
  const languages = getLanguages();
46
66
  const defaultLanguage = languages[0] ?? 'en';
@@ -122,6 +142,10 @@ function renderIconsInTree<T>(node: T, iconLibrary: 'fontawesome' | 'lucide' | '
122
142
  out[key] = <VeluIcon name={value} iconType={iconType} library={iconLibrary} fallback={false} />;
123
143
  continue;
124
144
  }
145
+ if (key === 'url' && typeof value === 'string') {
146
+ out[key] = withTrailingSlashUrl(value);
147
+ continue;
148
+ }
125
149
  out[key] = renderIconsInTree(value, iconLibrary);
126
150
  }
127
151
  return out as T;
@@ -347,12 +371,12 @@ export default async function SlugLayout({ children, params }: SlugLayoutProps)
347
371
  return {
348
372
  type: 'menu',
349
373
  text: tabText,
350
- url: tab.url,
374
+ url: withTrailingSlashUrl(tab.url),
351
375
  active: 'nested-url',
352
376
  secondary: false,
353
377
  items: menuLinks.map((item) => ({
354
378
  text: item.text,
355
- url: item.url,
379
+ url: withTrailingSlashUrl(item.url),
356
380
  active: 'nested-url',
357
381
  })),
358
382
  };
@@ -360,7 +384,7 @@ export default async function SlugLayout({ children, params }: SlugLayoutProps)
360
384
 
361
385
  return {
362
386
  text: tabText,
363
- url: tab.url,
387
+ url: withTrailingSlashUrl(tab.url),
364
388
  active: 'nested-url',
365
389
  secondary: false,
366
390
  };
@@ -215,6 +215,13 @@ function normalizeDocPath(value: string): string {
215
215
  return collapsed;
216
216
  }
217
217
 
218
+ function withTrailingSlashPath(path: string): string {
219
+ if (!path.startsWith('/')) return path;
220
+ if (path === '/' || path.endsWith('/')) return path;
221
+ if (/\.[a-zA-Z0-9]+$/.test(path)) return path;
222
+ return `${path}/`;
223
+ }
224
+
218
225
  function toAbsoluteMetaUrl(origin: string, value: string): string {
219
226
  const trimmed = value.trim();
220
227
  if (!trimmed) return trimmed;
@@ -614,7 +621,7 @@ export default async function Page({ params }: PageProps) {
614
621
  const pageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
615
622
  ? sourcePageUrl
616
623
  : (fallbackPath === '' ? '/' : fallbackPath);
617
- const rssHref = `${pageUrl.replace(/\/$/, '') || ''}/rss.xml`;
624
+ const rssHref = `${withTrailingSlashPath(pageUrl).replace(/\/$/, '') || ''}/rss.xml`;
618
625
  const shouldReplaceTocWithApiExample = !hasExplicitApiRendering && Boolean(inlineApiDoc) && playgroundDisplay === 'interactive';
619
626
  const shouldShowOpenApiExampleInToc = !hasExplicitApiRendering && !parsedApiFrontmatter && Boolean(parsedOpenApiFrontmatter);
620
627
  const hasApiTocRail = shouldReplaceTocWithApiExample || shouldShowOpenApiExampleInToc;
@@ -741,7 +748,7 @@ export default async function Page({ params }: PageProps) {
741
748
  {(previousPage || nextPage) ? (
742
749
  <div className={['velu-page-nav-grid', previousPage && nextPage ? 'velu-page-nav-grid-two' : 'velu-page-nav-grid-one'].join(' ')}>
743
750
  {previousPage ? (
744
- <a href={previousPage.url} className="velu-page-nav-card">
751
+ <a href={withTrailingSlashPath(previousPage.url)} className="velu-page-nav-card">
745
752
  <p className="velu-page-nav-title">{previousPage.data.title}</p>
746
753
  <p className="velu-page-nav-meta">
747
754
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg>
@@ -750,7 +757,7 @@ export default async function Page({ params }: PageProps) {
750
757
  </a>
751
758
  ) : null}
752
759
  {nextPage ? (
753
- <a href={nextPage.url} className="velu-page-nav-card velu-page-nav-card-next">
760
+ <a href={withTrailingSlashPath(nextPage.url)} className="velu-page-nav-card velu-page-nav-card-next">
754
761
  <p className="velu-page-nav-title">{nextPage.data.title}</p>
755
762
  <p className="velu-page-nav-meta velu-page-nav-meta-next">
756
763
  <span>{nextPage.data.description ?? 'Next'}</span>
@@ -37,7 +37,10 @@ function resolveDefaultDocsHref(): string {
37
37
  const defaultLanguage = getLanguages()[0] ?? 'en';
38
38
  const tree = source.getPageTree(defaultLanguage);
39
39
  const first = findFirstPageUrl(tree);
40
- return first || '/';
40
+ if (!first || first === '/') return '/';
41
+ if (!first.startsWith('/')) return first;
42
+ if (first.endsWith('/')) return first;
43
+ return `${first}/`;
41
44
  }
42
45
 
43
46
  export default function HomePage() {
@@ -30,6 +30,12 @@ function nativeLabel(code: string): string {
30
30
  return code.toUpperCase();
31
31
  }
32
32
 
33
+ function withTrailingSlashPath(path: string): string {
34
+ if (!path.startsWith('/')) return path;
35
+ if (path === '/' || path.endsWith('/')) return path;
36
+ return `${path}/`;
37
+ }
38
+
33
39
  export function LanguageSwitcher({ languages, defaultLang }: { languages: string[]; defaultLang: string }) {
34
40
  const [open, setOpen] = useState(false);
35
41
  const [mounted, setMounted] = useState(false);
@@ -63,7 +69,7 @@ export function LanguageSwitcher({ languages, defaultLang }: { languages: string
63
69
  const newPath = code === defaultLang
64
70
  ? '/' + rest.join('/')
65
71
  : '/' + code + '/' + rest.join('/');
66
- window.location.href = newPath;
72
+ window.location.href = withTrailingSlashPath(newPath);
67
73
  }
68
74
 
69
75
  return (
@@ -5,6 +5,14 @@ import { ThumbsDown, ThumbsUp } from 'lucide-react';
5
5
 
6
6
  type Vote = 'yes' | 'no';
7
7
 
8
+ const YES_OPTIONS = [
9
+ 'The guide worked as expected',
10
+ 'It was easy to find the information I needed',
11
+ 'It was easy to understand the product and features',
12
+ 'The documentation is up to date',
13
+ 'Something else',
14
+ ];
15
+
8
16
  const NO_OPTIONS = [
9
17
  'Help me get started faster',
10
18
  'Make it easier to find what I\'m looking for',
@@ -19,7 +27,8 @@ export function PageFeedback() {
19
27
  const [details, setDetails] = useState('');
20
28
  const [email, setEmail] = useState('');
21
29
 
22
- const showForm = vote === 'no';
30
+ const showForm = vote !== null;
31
+ const options = vote === 'yes' ? YES_OPTIONS : NO_OPTIONS;
23
32
  const showOptionalInputs = selectedReason === 'Something else';
24
33
 
25
34
  const onChooseVote = (value: Vote) => {
@@ -81,10 +90,12 @@ export function PageFeedback() {
81
90
 
82
91
  {showForm ? (
83
92
  <div className="velu-page-feedback-panel">
84
- <h3 className="velu-page-feedback-panel-title">How can we improve our product?</h3>
93
+ <h3 className="velu-page-feedback-panel-title">
94
+ {vote === 'yes' ? 'Great! What worked best for you?' : 'How can we improve our product?'}
95
+ </h3>
85
96
 
86
97
  <div className="velu-page-feedback-options" role="radiogroup" aria-label="Feedback reasons">
87
- {NO_OPTIONS.map((option) => {
98
+ {options.map((option) => {
88
99
  const checked = selectedReason === option;
89
100
  return (
90
101
  <button
@@ -14,6 +14,12 @@ function ChevronDownIcon() {
14
14
  );
15
15
  }
16
16
 
17
+ function withTrailingSlashPath(path: string): string {
18
+ if (!path.startsWith('/')) return path;
19
+ if (path === '/' || path.endsWith('/')) return path;
20
+ return `${path}/`;
21
+ }
22
+
17
23
  export function ProductSwitcher({
18
24
  products,
19
25
  iconLibrary,
@@ -50,12 +56,12 @@ export function ProductSwitcher({
50
56
  // Replace the product segment, keep tab/group/page segments
51
57
  const rest = segments.slice(1);
52
58
  if (rest.length > 0) {
53
- window.location.href = '/' + [target.slug, ...rest].join('/');
59
+ window.location.href = withTrailingSlashPath('/' + [target.slug, ...rest].join('/'));
54
60
  return;
55
61
  }
56
62
  }
57
63
 
58
- window.location.href = target.defaultPath;
64
+ window.location.href = withTrailingSlashPath(target.defaultPath);
59
65
  }
60
66
 
61
67
  return (
@@ -22,6 +22,12 @@ function ChevronDownIcon() {
22
22
  );
23
23
  }
24
24
 
25
+ function withTrailingSlashPath(path: string): string {
26
+ if (!path.startsWith('/')) return path;
27
+ if (path === '/' || path.endsWith('/')) return path;
28
+ return `${path}/`;
29
+ }
30
+
25
31
  export function VersionSwitcher({ versions }: { versions: VeluVersionOption[] }) {
26
32
  const pathname = usePathname();
27
33
  const [open, setOpen] = useState(false);
@@ -55,12 +61,12 @@ export function VersionSwitcher({ versions }: { versions: VeluVersionOption[] })
55
61
  const targetTab = target.tabSlugs[index] ?? target.tabSlugs[0];
56
62
  if (targetTab) {
57
63
  const rest = segments.slice(1);
58
- window.location.href = '/' + [targetTab, ...rest].join('/');
64
+ window.location.href = withTrailingSlashPath('/' + [targetTab, ...rest].join('/'));
59
65
  return;
60
66
  }
61
67
  }
62
68
 
63
- window.location.href = target.defaultPath;
69
+ window.location.href = withTrailingSlashPath(target.defaultPath);
64
70
  }
65
71
 
66
72
  return (
@@ -211,6 +211,12 @@ function pageBasename(page: string): string {
211
211
  return parts[parts.length - 1] ?? page;
212
212
  }
213
213
 
214
+ function withTrailingSlashPath(path: string): string {
215
+ if (!path.startsWith('/')) return path;
216
+ if (path === '/' || path.endsWith('/')) return path;
217
+ return `${path}/`;
218
+ }
219
+
214
220
  function findFirstPageInGroup(group: VeluGroup): string | undefined {
215
221
  for (const item of group.pages) {
216
222
  if (typeof item === 'string') return item;
@@ -382,7 +388,7 @@ export function getProductOptions(): VeluProductOption[] {
382
388
  icon: product.icon,
383
389
  iconType: product.iconType,
384
390
  tabSlugs,
385
- defaultPath,
391
+ defaultPath: withTrailingSlashPath(defaultPath),
386
392
  };
387
393
  });
388
394
  }
@@ -417,7 +423,7 @@ export function getVersionOptions(): VeluVersionOption[] {
417
423
  explicitDefault: version.default === true,
418
424
  versionParts: parseVersionParts(version.version),
419
425
  tabSlugs,
420
- defaultPath,
426
+ defaultPath: withTrailingSlashPath(defaultPath),
421
427
  order: index,
422
428
  };
423
429
  });
@@ -9,6 +9,10 @@ const withMDX = createMDX({
9
9
  const config = {
10
10
  reactStrictMode: false,
11
11
  output: process.env.NODE_ENV === 'production' ? 'export' : undefined,
12
+ // For static hosts without rewrite rules, emit directory routes
13
+ // (e.g. /docs/page/index.html) so extensionless URLs resolve.
14
+ trailingSlash: true,
15
+ skipTrailingSlashRedirect: true,
12
16
  distDir: 'dist',
13
17
  devIndicators: false,
14
18
  turbopack: {