@aravindc26/velu 0.11.15 → 0.11.17

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.15",
3
+ "version": "0.11.17",
4
4
  "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,6 +92,26 @@
92
92
  ],
93
93
  "default": "system"
94
94
  },
95
+ "fonts": {
96
+ "description": "Custom font configuration. Can be a single font definition (applied to both headings and body) or an object with separate heading and body font definitions.",
97
+ "oneOf": [
98
+ { "$ref": "#/definitions/fontDefinition" },
99
+ {
100
+ "type": "object",
101
+ "properties": {
102
+ "heading": {
103
+ "$ref": "#/definitions/fontDefinition",
104
+ "description": "Font used for headings (h1–h6)."
105
+ },
106
+ "body": {
107
+ "$ref": "#/definitions/fontDefinition",
108
+ "description": "Font used for body text."
109
+ }
110
+ },
111
+ "additionalProperties": false
112
+ }
113
+ ]
114
+ },
95
115
  "icons": {
96
116
  "type": "object",
97
117
  "description": "Icon settings compatible with Mint-style configuration.",
@@ -112,44 +132,6 @@
112
132
  ],
113
133
  "additionalProperties": false
114
134
  },
115
- "styling": {
116
- "type": "object",
117
- "description": "Fine-grained styling options.",
118
- "properties": {
119
- "codeblocks": {
120
- "type": "object",
121
- "description": "Code block styling options.",
122
- "properties": {
123
- "theme": {
124
- "description": "Shiki theme for syntax highlighting. Use a string for a single theme, or an object with light/dark keys.",
125
- "oneOf": [
126
- {
127
- "type": "string"
128
- },
129
- {
130
- "type": "object",
131
- "properties": {
132
- "light": {
133
- "type": "string"
134
- },
135
- "dark": {
136
- "type": "string"
137
- }
138
- },
139
- "required": [
140
- "light",
141
- "dark"
142
- ],
143
- "additionalProperties": false
144
- }
145
- ]
146
- }
147
- },
148
- "additionalProperties": false
149
- }
150
- },
151
- "additionalProperties": false
152
- },
153
135
  "languages": {
154
136
  "type": "array",
155
137
  "description": "Supported language codes (e.g. [\"en\", \"es\", \"ja\"]). The first is the default. Display labels are auto-derived.",
@@ -658,6 +640,32 @@
658
640
  },
659
641
  "additionalProperties": false,
660
642
  "definitions": {
643
+ "fontDefinition": {
644
+ "type": "object",
645
+ "description": "A font definition with family name and optional weight, source URL, and format.",
646
+ "properties": {
647
+ "family": {
648
+ "type": "string",
649
+ "description": "The font family name, e.g. \"Inter\", \"Playfair Display\"."
650
+ },
651
+ "weight": {
652
+ "type": "number",
653
+ "description": "The font weight, e.g. 400, 700. Variable font weights like 550 are supported."
654
+ },
655
+ "source": {
656
+ "type": "string",
657
+ "minLength": 1,
658
+ "description": "URL or path to a custom font file, e.g. /fonts/custom.woff2 or https://example.com/font.woff2."
659
+ },
660
+ "format": {
661
+ "type": "string",
662
+ "enum": ["woff", "woff2"],
663
+ "description": "The font file format when using a custom source."
664
+ }
665
+ },
666
+ "required": ["family"],
667
+ "additionalProperties": false
668
+ },
661
669
  "footerSocials": {
662
670
  "type": "object",
663
671
  "description": "Map of social network keys to URLs (for example {\"x\":\"https://x.com/org\",\"github\":\"https://github.com/org\"}).",
package/src/build.ts CHANGED
@@ -2,19 +2,19 @@ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, existsSyn
2
2
  import { join, dirname, relative, extname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { parse as parseYaml } from "yaml";
5
- import { generateThemeCss, resolveThemeName, type VeluColors, type VeluStyling } from "./themes.js";
5
+ import { generateThemeCss, resolveThemeName, type VeluColors } from "./themes.js";
6
6
  import { normalizeConfigNavigation } from "./navigation-normalize.js";
7
7
 
8
8
  // ── Engine directory (shipped with the CLI package) ──────────────────────────
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = dirname(__filename);
11
11
  const PACKAGED_ENGINE_DIR = join(__dirname, "engine");
12
- const DEV_ENGINE_DIR = join(__dirname, "..", "src", "engine");
13
- const ENGINE_DIR = existsSync(DEV_ENGINE_DIR) ? DEV_ENGINE_DIR : PACKAGED_ENGINE_DIR;
14
- const CLI_PACKAGE_JSON_PATH = join(__dirname, "..", "package.json");
15
- const PRIMARY_CONFIG_NAME = "docs.json";
16
- const LEGACY_CONFIG_NAME = "velu.json";
17
- const SOURCE_MIRROR_DIR = "velu-imports";
12
+ const DEV_ENGINE_DIR = join(__dirname, "..", "src", "engine");
13
+ const ENGINE_DIR = existsSync(DEV_ENGINE_DIR) ? DEV_ENGINE_DIR : PACKAGED_ENGINE_DIR;
14
+ const CLI_PACKAGE_JSON_PATH = join(__dirname, "..", "package.json");
15
+ const PRIMARY_CONFIG_NAME = "docs.json";
16
+ const LEGACY_CONFIG_NAME = "velu.json";
17
+ const SOURCE_MIRROR_DIR = "velu-imports";
18
18
 
19
19
  const SOURCE_MIRROR_EXTENSIONS = new Set([
20
20
  ".md", ".mdx", ".jsx", ".js", ".tsx", ".ts",
@@ -25,11 +25,11 @@ const SOURCE_MIRROR_EXTENSIONS = new Set([
25
25
  ".pdf", ".txt", ".xml", ".csv", ".zip",
26
26
  ]);
27
27
 
28
- const IMPORT_REWRITE_EXTENSIONS = new Set([".md", ".mdx", ".jsx", ".js", ".tsx", ".ts"]);
29
- const VARIABLE_SUBSTITUTION_EXTENSIONS = new Set([
30
- ".md", ".mdx", ".jsx", ".js", ".tsx", ".ts",
31
- ".json", ".yaml", ".yml", ".css", ".txt", ".xml", ".csv",
32
- ]);
28
+ const IMPORT_REWRITE_EXTENSIONS = new Set([".md", ".mdx", ".jsx", ".js", ".tsx", ".ts"]);
29
+ const VARIABLE_SUBSTITUTION_EXTENSIONS = new Set([
30
+ ".md", ".mdx", ".jsx", ".js", ".tsx", ".ts",
31
+ ".json", ".yaml", ".yml", ".css", ".txt", ".xml", ".csv",
32
+ ]);
33
33
 
34
34
  function resolveConfigPath(docsDir: string): string {
35
35
  const primary = join(docsDir, PRIMARY_CONFIG_NAME);
@@ -136,22 +136,22 @@ interface VeluRedirect {
136
136
  permanent?: boolean;
137
137
  }
138
138
 
139
- interface VeluConfig {
140
- $schema?: string;
141
- name?: string;
142
- title?: string;
143
- description?: string;
144
- theme?: string;
145
- variables?: Record<string, string>;
146
- colors?: VeluColors;
147
- appearance?: "system" | "light" | "dark";
148
- styling?: VeluStyling;
149
- metadata?: {
150
- timestamp?: boolean;
151
- };
152
- openapi?: VeluOpenApiSource;
153
- languages?: string[];
154
- redirects?: VeluRedirect[];
139
+ interface VeluConfig {
140
+ $schema?: string;
141
+ name?: string;
142
+ title?: string;
143
+ description?: string;
144
+ theme?: string;
145
+ variables?: Record<string, string>;
146
+ colors?: VeluColors;
147
+ appearance?: "system" | "light" | "dark";
148
+ fonts?: { family: string; weight?: number; source?: string; format?: "woff" | "woff2" } | { heading?: { family: string; weight?: number; source?: string; format?: "woff" | "woff2" }; body?: { family: string; weight?: number; source?: string; format?: "woff" | "woff2" } };
149
+ metadata?: {
150
+ timestamp?: boolean;
151
+ };
152
+ openapi?: VeluOpenApiSource;
153
+ languages?: string[];
154
+ redirects?: VeluRedirect[];
155
155
  navigation: {
156
156
  openapi?: VeluOpenApiSource;
157
157
  tabs?: VeluTab[];
@@ -405,115 +405,115 @@ function normalizeOpenApiSpecForFrontmatter(spec: string | undefined): string |
405
405
  return `/${trimmed.replace(/^\.?\/*/, "")}`;
406
406
  }
407
407
 
408
- // ── Helpers ────────────────────────────────────────────────────────────────────
409
-
410
- const VARIABLE_TOKEN_PATTERN = /\{\{\s*([A-Za-z0-9.-]+)\s*\}\}/g;
411
- const VARIABLE_NAME_PATTERN = /^[A-Za-z0-9.-]+$/;
412
-
413
- function sanitizeVariableValue(value: string): string {
414
- return value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
415
- }
416
-
417
- function extractVariables(input: unknown): Record<string, string> {
418
- if (!input || typeof input !== "object" || Array.isArray(input)) return {};
419
-
420
- const output: Record<string, string> = {};
421
- for (const [rawKey, rawValue] of Object.entries(input as Record<string, unknown>)) {
422
- const key = rawKey.trim();
423
- if (!key) continue;
424
- if (!VARIABLE_NAME_PATTERN.test(key)) {
425
- throw new Error(`Invalid variable name '${rawKey}'. Variable names can only contain letters, numbers, periods, and hyphens.`);
426
- }
427
- if (typeof rawValue !== "string") {
428
- throw new Error(`Invalid value for variable '${rawKey}'. Variables must be strings.`);
429
- }
430
- output[key] = rawValue;
431
- }
432
- return output;
433
- }
434
-
435
- function resolveVariableMap(rawVariables: Record<string, string>): Record<string, string> {
436
- const cache = new Map<string, string>();
437
- const activeStack = new Set<string>();
438
-
439
- function resolveOne(name: string): string {
440
- const cached = cache.get(name);
441
- if (cached !== undefined) return cached;
442
-
443
- if (activeStack.has(name)) {
444
- throw new Error(`Circular variable reference detected for '{{${name}}}'.`);
445
- }
446
-
447
- const raw = rawVariables[name];
448
- if (raw === undefined) {
449
- throw new Error(`Undefined variable '{{${name}}}' referenced in variable definitions.`);
450
- }
451
-
452
- activeStack.add(name);
453
- const resolved = raw.replace(VARIABLE_TOKEN_PATTERN, (_match, token: string) => resolveOne(token));
454
- activeStack.delete(name);
455
- cache.set(name, resolved);
456
- return resolved;
457
- }
458
-
459
- const output: Record<string, string> = {};
460
- for (const name of Object.keys(rawVariables)) {
461
- output[name] = resolveOne(name);
462
- }
463
- return output;
464
- }
465
-
466
- function replaceVariablesInString(
467
- value: string,
468
- variables: Record<string, string>,
469
- context: string,
470
- sanitizeValues: boolean,
471
- ): string {
472
- const undefinedVariables = new Set<string>();
473
- const replaced = value.replace(VARIABLE_TOKEN_PATTERN, (match, rawName: string) => {
474
- const name = rawName.trim();
475
- const resolved = variables[name];
476
- if (resolved === undefined) {
477
- undefinedVariables.add(name);
478
- return match;
479
- }
480
- return sanitizeValues ? sanitizeVariableValue(resolved) : resolved;
481
- });
482
-
483
- if (undefinedVariables.size > 0) {
484
- throw new Error(
485
- `Undefined variable(s) ${Array.from(undefinedVariables).map((name) => `'{{${name}}}'`).join(", ")} in ${context}.`
486
- );
487
- }
488
-
489
- return replaced;
490
- }
491
-
492
- function applyVariablesToConfig(value: unknown, variables: Record<string, string>, path = "docs.json"): unknown {
493
- if (typeof value === "string") return replaceVariablesInString(value, variables, path, false);
494
- if (Array.isArray(value)) return value.map((entry, index) => applyVariablesToConfig(entry, variables, `${path}[${index}]`));
495
- if (!value || typeof value !== "object") return value;
496
-
497
- const output: Record<string, unknown> = {};
498
- for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
499
- output[key] = applyVariablesToConfig(entry, variables, `${path}.${key}`);
500
- }
501
- return output;
502
- }
503
-
504
- function loadConfig(docsDir: string): { config: VeluConfig; rawConfig: VeluConfig; variables: Record<string, string> } {
505
- const raw = readFileSync(resolveConfigPath(docsDir), "utf-8");
506
- const parsed = JSON.parse(raw) as VeluConfig;
507
- const rawVariables = extractVariables(parsed.variables);
508
- const resolvedVariables = resolveVariableMap(rawVariables);
509
- const withVariables = applyVariablesToConfig(parsed, resolvedVariables) as VeluConfig;
510
- withVariables.variables = resolvedVariables;
511
- return {
512
- config: normalizeConfigNavigation(withVariables),
513
- rawConfig: withVariables,
514
- variables: resolvedVariables,
515
- };
516
- }
408
+ // ── Helpers ────────────────────────────────────────────────────────────────────
409
+
410
+ const VARIABLE_TOKEN_PATTERN = /\{\{\s*([A-Za-z0-9.-]+)\s*\}\}/g;
411
+ const VARIABLE_NAME_PATTERN = /^[A-Za-z0-9.-]+$/;
412
+
413
+ function sanitizeVariableValue(value: string): string {
414
+ return value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
415
+ }
416
+
417
+ function extractVariables(input: unknown): Record<string, string> {
418
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
419
+
420
+ const output: Record<string, string> = {};
421
+ for (const [rawKey, rawValue] of Object.entries(input as Record<string, unknown>)) {
422
+ const key = rawKey.trim();
423
+ if (!key) continue;
424
+ if (!VARIABLE_NAME_PATTERN.test(key)) {
425
+ throw new Error(`Invalid variable name '${rawKey}'. Variable names can only contain letters, numbers, periods, and hyphens.`);
426
+ }
427
+ if (typeof rawValue !== "string") {
428
+ throw new Error(`Invalid value for variable '${rawKey}'. Variables must be strings.`);
429
+ }
430
+ output[key] = rawValue;
431
+ }
432
+ return output;
433
+ }
434
+
435
+ function resolveVariableMap(rawVariables: Record<string, string>): Record<string, string> {
436
+ const cache = new Map<string, string>();
437
+ const activeStack = new Set<string>();
438
+
439
+ function resolveOne(name: string): string {
440
+ const cached = cache.get(name);
441
+ if (cached !== undefined) return cached;
442
+
443
+ if (activeStack.has(name)) {
444
+ throw new Error(`Circular variable reference detected for '{{${name}}}'.`);
445
+ }
446
+
447
+ const raw = rawVariables[name];
448
+ if (raw === undefined) {
449
+ throw new Error(`Undefined variable '{{${name}}}' referenced in variable definitions.`);
450
+ }
451
+
452
+ activeStack.add(name);
453
+ const resolved = raw.replace(VARIABLE_TOKEN_PATTERN, (_match, token: string) => resolveOne(token));
454
+ activeStack.delete(name);
455
+ cache.set(name, resolved);
456
+ return resolved;
457
+ }
458
+
459
+ const output: Record<string, string> = {};
460
+ for (const name of Object.keys(rawVariables)) {
461
+ output[name] = resolveOne(name);
462
+ }
463
+ return output;
464
+ }
465
+
466
+ function replaceVariablesInString(
467
+ value: string,
468
+ variables: Record<string, string>,
469
+ context: string,
470
+ sanitizeValues: boolean,
471
+ ): string {
472
+ const undefinedVariables = new Set<string>();
473
+ const replaced = value.replace(VARIABLE_TOKEN_PATTERN, (match, rawName: string) => {
474
+ const name = rawName.trim();
475
+ const resolved = variables[name];
476
+ if (resolved === undefined) {
477
+ undefinedVariables.add(name);
478
+ return match;
479
+ }
480
+ return sanitizeValues ? sanitizeVariableValue(resolved) : resolved;
481
+ });
482
+
483
+ if (undefinedVariables.size > 0) {
484
+ throw new Error(
485
+ `Undefined variable(s) ${Array.from(undefinedVariables).map((name) => `'{{${name}}}'`).join(", ")} in ${context}.`
486
+ );
487
+ }
488
+
489
+ return replaced;
490
+ }
491
+
492
+ function applyVariablesToConfig(value: unknown, variables: Record<string, string>, path = "docs.json"): unknown {
493
+ if (typeof value === "string") return replaceVariablesInString(value, variables, path, false);
494
+ if (Array.isArray(value)) return value.map((entry, index) => applyVariablesToConfig(entry, variables, `${path}[${index}]`));
495
+ if (!value || typeof value !== "object") return value;
496
+
497
+ const output: Record<string, unknown> = {};
498
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
499
+ output[key] = applyVariablesToConfig(entry, variables, `${path}.${key}`);
500
+ }
501
+ return output;
502
+ }
503
+
504
+ function loadConfig(docsDir: string): { config: VeluConfig; rawConfig: VeluConfig; variables: Record<string, string> } {
505
+ const raw = readFileSync(resolveConfigPath(docsDir), "utf-8");
506
+ const parsed = JSON.parse(raw) as VeluConfig;
507
+ const rawVariables = extractVariables(parsed.variables);
508
+ const resolvedVariables = resolveVariableMap(rawVariables);
509
+ const withVariables = applyVariablesToConfig(parsed, resolvedVariables) as VeluConfig;
510
+ withVariables.variables = resolvedVariables;
511
+ return {
512
+ config: normalizeConfigNavigation(withVariables),
513
+ rawConfig: withVariables,
514
+ variables: resolvedVariables,
515
+ };
516
+ }
517
517
 
518
518
  function isExternalDestination(value: string): boolean {
519
519
  return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value);
@@ -591,7 +591,7 @@ const STATIC_EXTENSIONS = new Set([
591
591
  ".zip",
592
592
  ]);
593
593
 
594
- function copyStaticAssets(docsDir: string, publicDir: string) {
594
+ function copyStaticAssets(docsDir: string, publicDir: string) {
595
595
  function walk(dir: string) {
596
596
  const entries = readdirSync(dir, { withFileTypes: true });
597
597
  for (const entry of entries) {
@@ -615,49 +615,49 @@ function copyStaticAssets(docsDir: string, publicDir: string) {
615
615
  }
616
616
  }
617
617
 
618
- walk(docsDir);
619
- }
620
-
621
- function resolveProjectName(config: VeluConfig): string {
622
- const fromName = typeof config.name === "string" ? config.name.trim() : "";
623
- if (fromName) return fromName;
624
- const fromTitle = typeof config.title === "string" ? config.title.trim() : "";
625
- if (fromTitle) return fromTitle;
626
- return "Documentation";
627
- }
628
-
629
- function resolveProjectDescription(config: VeluConfig): string {
630
- if (typeof config.description === "string") return config.description.trim();
631
- return "";
632
- }
633
-
634
- function resolveCliVersion(): string {
635
- try {
636
- const raw = readFileSync(CLI_PACKAGE_JSON_PATH, "utf-8");
637
- const parsed = JSON.parse(raw) as { version?: unknown };
638
- if (typeof parsed.version === "string" && parsed.version.trim().length > 0) {
639
- return parsed.version.trim();
640
- }
641
- } catch {
642
- // ignore and fallback
643
- }
644
- return "unknown";
645
- }
646
-
647
- function writeProjectConstFile(config: VeluConfig, outDir: string) {
648
- const constPayload = {
649
- name: resolveProjectName(config),
650
- description: resolveProjectDescription(config),
651
- version: resolveCliVersion(),
652
- };
653
-
654
- const constPath = join(outDir, "public", "const.json");
655
- writeFileSync(constPath, `${JSON.stringify(constPayload, null, 2)}\n`, "utf-8");
656
- }
657
-
658
- function toPosixPath(value: string): string {
659
- return value.replace(/\\/g, "/");
660
- }
618
+ walk(docsDir);
619
+ }
620
+
621
+ function resolveProjectName(config: VeluConfig): string {
622
+ const fromName = typeof config.name === "string" ? config.name.trim() : "";
623
+ if (fromName) return fromName;
624
+ const fromTitle = typeof config.title === "string" ? config.title.trim() : "";
625
+ if (fromTitle) return fromTitle;
626
+ return "Documentation";
627
+ }
628
+
629
+ function resolveProjectDescription(config: VeluConfig): string {
630
+ if (typeof config.description === "string") return config.description.trim();
631
+ return "";
632
+ }
633
+
634
+ function resolveCliVersion(): string {
635
+ try {
636
+ const raw = readFileSync(CLI_PACKAGE_JSON_PATH, "utf-8");
637
+ const parsed = JSON.parse(raw) as { version?: unknown };
638
+ if (typeof parsed.version === "string" && parsed.version.trim().length > 0) {
639
+ return parsed.version.trim();
640
+ }
641
+ } catch {
642
+ // ignore and fallback
643
+ }
644
+ return "unknown";
645
+ }
646
+
647
+ function writeProjectConstFile(config: VeluConfig, outDir: string) {
648
+ const constPayload = {
649
+ name: resolveProjectName(config),
650
+ description: resolveProjectDescription(config),
651
+ version: resolveCliVersion(),
652
+ };
653
+
654
+ const constPath = join(outDir, "public", "const.json");
655
+ writeFileSync(constPath, `${JSON.stringify(constPayload, null, 2)}\n`, "utf-8");
656
+ }
657
+
658
+ function toPosixPath(value: string): string {
659
+ return value.replace(/\\/g, "/");
660
+ }
661
661
 
662
662
  function isInsideDocsRoot(docsDir: string, targetPath: string): boolean {
663
663
  const relPath = relative(docsDir, targetPath);
@@ -824,41 +824,41 @@ function rewriteImportsInContent(
824
824
  return out.join("\n");
825
825
  }
826
826
 
827
- function copyMirroredSourceFile(
828
- srcPath: string,
829
- docsDir: string,
830
- mirrorDir: string,
831
- variables: Record<string, string>,
832
- ) {
833
- if (!shouldMirrorSourceFile(srcPath)) return;
834
- if (!isInsideDocsRoot(docsDir, srcPath)) return;
835
-
836
- const relPath = relative(docsDir, srcPath);
837
- const destPath = join(mirrorDir, relPath);
838
- mkdirSync(dirname(destPath), { recursive: true });
839
-
840
- if (shouldRewriteImports(srcPath)) {
841
- let raw = readFileSync(srcPath, "utf-8");
842
- raw = replaceVariablesInString(raw, variables, relPath, true);
843
- const rewritten = rewriteImportsInContent(raw, srcPath, destPath, docsDir, mirrorDir);
844
- writeFileSync(destPath, rewritten, "utf-8");
845
- return;
846
- }
847
-
848
- const extension = extname(srcPath).toLowerCase();
849
- if (VARIABLE_SUBSTITUTION_EXTENSIONS.has(extension)) {
850
- const raw = readFileSync(srcPath, "utf-8");
851
- const substituted = replaceVariablesInString(raw, variables, relPath, true);
852
- writeFileSync(destPath, substituted, "utf-8");
853
- return;
854
- }
855
-
856
- copyFileSync(srcPath, destPath);
857
- }
858
-
859
- function rebuildSourceMirror(docsDir: string, mirrorDir: string, variables: Record<string, string>) {
860
- rmSync(mirrorDir, { recursive: true, force: true });
861
- mkdirSync(mirrorDir, { recursive: true });
827
+ function copyMirroredSourceFile(
828
+ srcPath: string,
829
+ docsDir: string,
830
+ mirrorDir: string,
831
+ variables: Record<string, string>,
832
+ ) {
833
+ if (!shouldMirrorSourceFile(srcPath)) return;
834
+ if (!isInsideDocsRoot(docsDir, srcPath)) return;
835
+
836
+ const relPath = relative(docsDir, srcPath);
837
+ const destPath = join(mirrorDir, relPath);
838
+ mkdirSync(dirname(destPath), { recursive: true });
839
+
840
+ if (shouldRewriteImports(srcPath)) {
841
+ let raw = readFileSync(srcPath, "utf-8");
842
+ raw = replaceVariablesInString(raw, variables, relPath, true);
843
+ const rewritten = rewriteImportsInContent(raw, srcPath, destPath, docsDir, mirrorDir);
844
+ writeFileSync(destPath, rewritten, "utf-8");
845
+ return;
846
+ }
847
+
848
+ const extension = extname(srcPath).toLowerCase();
849
+ if (VARIABLE_SUBSTITUTION_EXTENSIONS.has(extension)) {
850
+ const raw = readFileSync(srcPath, "utf-8");
851
+ const substituted = replaceVariablesInString(raw, variables, relPath, true);
852
+ writeFileSync(destPath, substituted, "utf-8");
853
+ return;
854
+ }
855
+
856
+ copyFileSync(srcPath, destPath);
857
+ }
858
+
859
+ function rebuildSourceMirror(docsDir: string, mirrorDir: string, variables: Record<string, string>) {
860
+ rmSync(mirrorDir, { recursive: true, force: true });
861
+ mkdirSync(mirrorDir, { recursive: true });
862
862
 
863
863
  function walk(dir: string) {
864
864
  const entries = readdirSync(dir, { withFileTypes: true });
@@ -866,14 +866,14 @@ function rebuildSourceMirror(docsDir: string, mirrorDir: string, variables: Reco
866
866
  if (entry.name.startsWith(".")) continue;
867
867
  if (entry.name === "node_modules") continue;
868
868
  const srcPath = join(dir, entry.name);
869
- if (entry.isDirectory()) {
870
- walk(srcPath);
871
- continue;
872
- }
873
- if (!shouldMirrorSourceFile(srcPath)) continue;
874
- copyMirroredSourceFile(srcPath, docsDir, mirrorDir, variables);
875
- }
876
- }
869
+ if (entry.isDirectory()) {
870
+ walk(srcPath);
871
+ continue;
872
+ }
873
+ if (!shouldMirrorSourceFile(srcPath)) continue;
874
+ copyMirroredSourceFile(srcPath, docsDir, mirrorDir, variables);
875
+ }
876
+ }
877
877
 
878
878
  walk(docsDir);
879
879
  }
@@ -1179,11 +1179,11 @@ function buildArtifacts(config: VeluConfig, docsDirForOpenApi?: string): BuildAr
1179
1179
 
1180
1180
  // ── Build ──────────────────────────────────────────────────────────────────────
1181
1181
 
1182
- function build(docsDir: string, outDir: string) {
1183
- const configPath = resolveConfigPath(docsDir);
1184
- const configName = configPath.endsWith(PRIMARY_CONFIG_NAME) ? PRIMARY_CONFIG_NAME : LEGACY_CONFIG_NAME;
1185
- console.log(`📖 Loading ${configName} from: ${docsDir}`);
1186
- const { config, rawConfig, variables } = loadConfig(docsDir);
1182
+ function build(docsDir: string, outDir: string) {
1183
+ const configPath = resolveConfigPath(docsDir);
1184
+ const configName = configPath.endsWith(PRIMARY_CONFIG_NAME) ? PRIMARY_CONFIG_NAME : LEGACY_CONFIG_NAME;
1185
+ console.log(`📖 Loading ${configName} from: ${docsDir}`);
1186
+ const { config, rawConfig, variables } = loadConfig(docsDir);
1187
1187
 
1188
1188
  if (existsSync(outDir)) {
1189
1189
  rmSync(outDir, { recursive: true, force: true });
@@ -1196,39 +1196,39 @@ function build(docsDir: string, outDir: string) {
1196
1196
  console.log("📦 Copied engine files");
1197
1197
 
1198
1198
  // ── 2. Create additional directories ─────────────────────────────────────
1199
- mkdirSync(join(outDir, "content", "docs"), { recursive: true });
1200
- mkdirSync(join(outDir, "public"), { recursive: true });
1201
- const sourceMirrorDir = join(outDir, SOURCE_MIRROR_DIR);
1202
- rebuildSourceMirror(docsDir, sourceMirrorDir, variables);
1199
+ mkdirSync(join(outDir, "content", "docs"), { recursive: true });
1200
+ mkdirSync(join(outDir, "public"), { recursive: true });
1201
+ const sourceMirrorDir = join(outDir, SOURCE_MIRROR_DIR);
1202
+ rebuildSourceMirror(docsDir, sourceMirrorDir, variables);
1203
1203
 
1204
1204
  // ── 3. Copy config into the generated project ────────────────────────────
1205
- const serializedConfig = `${JSON.stringify(rawConfig, null, 2)}\n`;
1206
- writeFileSync(join(outDir, PRIMARY_CONFIG_NAME), serializedConfig, "utf-8");
1207
- writeFileSync(join(outDir, LEGACY_CONFIG_NAME), serializedConfig, "utf-8");
1208
- console.log(`📋 Copied ${configName} as ${PRIMARY_CONFIG_NAME} (and legacy ${LEGACY_CONFIG_NAME})`);
1205
+ const serializedConfig = `${JSON.stringify(rawConfig, null, 2)}\n`;
1206
+ writeFileSync(join(outDir, PRIMARY_CONFIG_NAME), serializedConfig, "utf-8");
1207
+ writeFileSync(join(outDir, LEGACY_CONFIG_NAME), serializedConfig, "utf-8");
1208
+ console.log(`📋 Copied ${configName} as ${PRIMARY_CONFIG_NAME} (and legacy ${LEGACY_CONFIG_NAME})`);
1209
1209
 
1210
1210
  // ── 3b. Copy static assets from docs project into public/ ─────────────────
1211
- copyStaticAssets(docsDir, join(outDir, "public"));
1212
- writeRedirectArtifacts(config, outDir);
1213
- writeProjectConstFile(rawConfig, outDir);
1214
- if ((config.redirects ?? []).length > 0) {
1215
- console.log("↪️ Generated redirect artifacts");
1216
- }
1217
- console.log("🧾 Generated const.json");
1218
- console.log("🖼️ Copied static assets");
1211
+ copyStaticAssets(docsDir, join(outDir, "public"));
1212
+ writeRedirectArtifacts(config, outDir);
1213
+ writeProjectConstFile(rawConfig, outDir);
1214
+ if ((config.redirects ?? []).length > 0) {
1215
+ console.log("↪️ Generated redirect artifacts");
1216
+ }
1217
+ console.log("🧾 Generated const.json");
1218
+ console.log("🖼️ Copied static assets");
1219
1219
 
1220
1220
  // ── 4. Build content + metadata artifacts ────────────────────────────────
1221
1221
  const contentDir = join(outDir, "content", "docs");
1222
1222
  const navLanguages = config.navigation.languages;
1223
1223
  const simpleLanguages = config.languages || [];
1224
1224
 
1225
- function processPage(srcPath: string, destPath: string, slug: string) {
1226
- mkdirSync(dirname(destPath), { recursive: true });
1227
- let content = readFileSync(srcPath, "utf-8");
1228
- content = replaceVariablesInString(content, variables, relative(docsDir, srcPath), true);
1229
- if (!content.startsWith("---")) {
1230
- const titleMatch = content.match(/^#\s+(.+)$/m);
1231
- const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
1225
+ function processPage(srcPath: string, destPath: string, slug: string) {
1226
+ mkdirSync(dirname(destPath), { recursive: true });
1227
+ let content = readFileSync(srcPath, "utf-8");
1228
+ content = replaceVariablesInString(content, variables, relative(docsDir, srcPath), true);
1229
+ if (!content.startsWith("---")) {
1230
+ const titleMatch = content.match(/^#\s+(.+)$/m);
1231
+ const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
1232
1232
  if (titleMatch) {
1233
1233
  content = content.replace(/^#\s+.+$/m, "").trimStart();
1234
1234
  }
@@ -1289,11 +1289,11 @@ function build(docsDir: string, outDir: string) {
1289
1289
  const descriptionLine = description ? `\ndescription: "${description}"` : "";
1290
1290
  const deprecatedLine = mapping.deprecated === true ? `\ndeprecated: true` : "";
1291
1291
  const statusLine = mapping.deprecated === true ? `\nstatus: "deprecated"` : "";
1292
- const versionLine = version ? `\nversion: "${version}"` : "";
1293
- const content = typeof mapping.content === "string"
1294
- ? `${replaceVariablesInString(mapping.content.trim(), variables, `openapi:${mapping.dest}`, true)}\n`
1295
- : "";
1296
- writeFileSync(
1292
+ const versionLine = version ? `\nversion: "${version}"` : "";
1293
+ const content = typeof mapping.content === "string"
1294
+ ? `${replaceVariablesInString(mapping.content.trim(), variables, `openapi:${mapping.dest}`, true)}\n`
1295
+ : "";
1296
+ writeFileSync(
1297
1297
  destPath,
1298
1298
  `---\ntitle: "${title}"${descriptionLine}${deprecatedLine}${statusLine}${versionLine}\nopenapi: "${openapi}"\n---\n${warning}${content}`,
1299
1299
  "utf-8",
@@ -1370,11 +1370,22 @@ function build(docsDir: string, outDir: string) {
1370
1370
  console.log(`📄 Generated ${totalPages} pages + ${totalMeta} navigation meta files`);
1371
1371
 
1372
1372
  // ── 5. Generate theme CSS (dynamic — depends on user config) ─────────────
1373
+ // Resolve fonts config into { heading?, body? } shape
1374
+ const resolvedFonts = (() => {
1375
+ const raw = config.fonts;
1376
+ if (!raw) return undefined;
1377
+ if ('family' in raw && typeof (raw as Record<string, unknown>).family === 'string') {
1378
+ return { heading: raw as { family: string; weight?: number; source?: string; format?: "woff" | "woff2" }, body: raw as { family: string; weight?: number; source?: string; format?: "woff" | "woff2" } };
1379
+ }
1380
+ const obj = raw as { heading?: { family: string; weight?: number; source?: string; format?: "woff" | "woff2" }; body?: { family: string; weight?: number; source?: string; format?: "woff" | "woff2" } };
1381
+ return (obj.heading || obj.body) ? obj : undefined;
1382
+ })();
1383
+
1373
1384
  const themeCss = generateThemeCss({
1374
1385
  theme: config.theme,
1375
1386
  colors: config.colors,
1376
1387
  appearance: config.appearance,
1377
- styling: config.styling,
1388
+ fonts: resolvedFonts,
1378
1389
  });
1379
1390
  writeFileSync(join(outDir, "app", "velu-theme.css"), themeCss, "utf-8");
1380
1391
  console.log(`🎨 Generated theme: ${resolveThemeName(config.theme)}`);
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, renameSync, readFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, copyFileSync, cpSync, rmSync, renameSync, readFileSync, statSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
@@ -237,6 +237,19 @@ function samePath(a: string, b: string): boolean {
237
237
  return resolve(a).replace(/\\/g, "/").toLowerCase() === resolve(b).replace(/\\/g, "/").toLowerCase();
238
238
  }
239
239
 
240
+ function copyDirMerge(src: string, dest: string): void {
241
+ mkdirSync(dest, { recursive: true });
242
+ for (const entry of readdirSync(src)) {
243
+ const srcPath = join(src, entry);
244
+ const destPath = join(dest, entry);
245
+ if (statSync(srcPath).isDirectory()) {
246
+ copyDirMerge(srcPath, destPath);
247
+ } else {
248
+ copyFileSync(srcPath, destPath);
249
+ }
250
+ }
251
+ }
252
+
240
253
  function prepareRuntimeOutDir(docsOutDir: string): string {
241
254
  const runtimeOutDir = join(PACKAGE_ROOT, ".velu-out");
242
255
  if (samePath(docsOutDir, runtimeOutDir)) return runtimeOutDir;
@@ -249,13 +262,24 @@ function prepareRuntimeOutDir(docsOutDir: string): string {
249
262
  const stale = `${runtimeOutDir}-stale-${Date.now()}`;
250
263
  try {
251
264
  renameSync(runtimeOutDir, stale);
252
- // Best-effort async cleanup — ignore errors if still locked.
253
265
  try { rmSync(stale, { recursive: true, force: true }); } catch {}
254
266
  } catch {
255
- // If even rename fails, try to overwrite in place.
267
+ // If even rename fails, clear contents so cpSync can work.
268
+ try {
269
+ for (const entry of readdirSync(runtimeOutDir)) {
270
+ rmSync(join(runtimeOutDir, entry), { recursive: true, force: true });
271
+ }
272
+ } catch {}
256
273
  }
257
274
  }
258
- cpSync(docsOutDir, runtimeOutDir, { recursive: true, force: true });
275
+
276
+ if (existsSync(runtimeOutDir)) {
277
+ // cpSync fails with EEXIST when the destination directory exists (Node 20).
278
+ // Fall back to a manual recursive copy that handles existing directories.
279
+ copyDirMerge(docsOutDir, runtimeOutDir);
280
+ } else {
281
+ cpSync(docsOutDir, runtimeOutDir, { recursive: true, force: true });
282
+ }
259
283
  return runtimeOutDir;
260
284
  }
261
285
 
@@ -987,14 +987,14 @@ nextjs-portal {
987
987
  display: grid !important;
988
988
  grid-template-columns: 2.25rem minmax(0, 1fr);
989
989
  column-gap: 0.75rem;
990
- align-items: start !important;
990
+ align-items: center !important;
991
991
  }
992
992
 
993
993
  [data-card].velu-card-horizontal > div.not-prose {
994
994
  grid-row: 1 / span 3;
995
995
  margin-bottom: 0;
996
- margin-top: 0.02rem;
997
- align-self: start;
996
+ margin-top: 0;
997
+ align-self: center;
998
998
  justify-self: start;
999
999
  }
1000
1000
 
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next';
2
2
  import type { ReactNode } from 'react';
3
- import { getAppearance, getBannerConfig, getSeoConfig, getSiteDescription, getSiteFavicon, getSiteName, getSiteOrigin, getSitePrimaryColor } from '@/lib/velu';
3
+ import { getAppearance, getBannerConfig, getFontsConfig, getSeoConfig, getSiteDescription, getSiteFavicon, getSiteName, getSiteOrigin, getSitePrimaryColor } from '@/lib/velu';
4
4
  import { Providers } from '@/components/providers';
5
5
  import { VeluAssistant } from '@/components/assistant';
6
6
  import { VeluBanner } from '@/components/banner';
@@ -24,6 +24,7 @@ const seo = getSeoConfig();
24
24
  const favicon = getSiteFavicon();
25
25
  const primaryColor = getSitePrimaryColor();
26
26
  const bannerConfig = getBannerConfig();
27
+ const fontsConfig = getFontsConfig();
27
28
  const generatedDefaultSocialImage = '/og/index.svg';
28
29
  const defaultSocialImage = seo.metatags['og:image'] ?? seo.metatags['twitter:image'] ?? generatedDefaultSocialImage;
29
30
  const absoluteDefaultSocialImage = defaultSocialImage ? toAbsoluteUrl(siteOrigin, defaultSocialImage) : undefined;
@@ -79,6 +80,19 @@ export default function RootLayout({ children }: { children: ReactNode }) {
79
80
  <html lang="en" suppressHydrationWarning>
80
81
  <head>
81
82
  <link rel="sitemap" type="application/xml" href={`${siteOrigin}/sitemap.xml`} />
83
+ {fontsConfig && (() => {
84
+ // Collect Google Fonts families that don't use a custom source
85
+ const families = new Set<string>();
86
+ for (const def of [fontsConfig.heading, fontsConfig.body]) {
87
+ if (def && !def.source) {
88
+ const weight = def.weight ? `:wght@${def.weight}` : ':wght@400;500;600;700';
89
+ families.add(`${def.family.replace(/ /g, '+')}${weight}`);
90
+ }
91
+ }
92
+ if (families.size === 0) return null;
93
+ const url = `https://fonts.googleapis.com/css2?${[...families].map(f => `family=${f}`).join('&')}&display=swap`;
94
+ return <link rel="stylesheet" href={url} />;
95
+ })()}
82
96
  </head>
83
97
  <body className="min-h-screen" suppressHydrationWarning>
84
98
  <Providers theme={theme}>
@@ -0,0 +1,83 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ buildPublicFeedbackPayload,
5
+ PUBLIC_API_BASE_URL,
6
+ PUBLIC_FEEDBACK_ENDPOINT_PATH,
7
+ resolvePublicFeedbackEndpoint,
8
+ submitPublicFeedback,
9
+ } from './page-feedback-api';
10
+
11
+ test('buildPublicFeedbackPayload returns contract body with optional fields', () => {
12
+ const payload = buildPublicFeedbackPayload({
13
+ pageUrl: 'https://docs.example.com/guide',
14
+ helpful: true,
15
+ reasonText: 'The guide worked as expected',
16
+ details: ' Extra details ',
17
+ email: ' user@example.com ',
18
+ });
19
+
20
+ assert.deepEqual(payload, {
21
+ page_url: 'https://docs.example.com/guide',
22
+ helpful: true,
23
+ reason_text: 'The guide worked as expected',
24
+ details: 'Extra details',
25
+ email: 'user@example.com',
26
+ });
27
+ });
28
+
29
+ test('resolvePublicFeedbackEndpoint appends the fixed endpoint path', () => {
30
+ assert.equal(
31
+ resolvePublicFeedbackEndpoint(),
32
+ `${PUBLIC_API_BASE_URL}${PUBLIC_FEEDBACK_ENDPOINT_PATH}`,
33
+ );
34
+ });
35
+
36
+ test('submitPublicFeedback posts the expected payload and headers', async () => {
37
+ let receivedUrl = '';
38
+ let receivedInit: RequestInit | undefined;
39
+
40
+ const fetchImpl: typeof fetch = async (input, init) => {
41
+ receivedUrl = String(input);
42
+ receivedInit = init;
43
+ return new Response(null, { status: 204 });
44
+ };
45
+
46
+ const result = await submitPublicFeedback({
47
+ pageUrl: 'https://docs.example.com/page',
48
+ helpful: false,
49
+ reasonText: 'Update this documentation',
50
+ details: '',
51
+ email: undefined,
52
+ siteHost: 'docs.example.com',
53
+ fetchImpl,
54
+ });
55
+
56
+ assert.deepEqual(result, { ok: true });
57
+ assert.equal(receivedUrl, `${PUBLIC_API_BASE_URL}${PUBLIC_FEEDBACK_ENDPOINT_PATH}`);
58
+ assert.equal(receivedInit?.method, 'POST');
59
+ assert.equal(receivedInit?.credentials, 'include');
60
+ assert.equal(receivedInit?.body, JSON.stringify({
61
+ page_url: 'https://docs.example.com/page',
62
+ helpful: false,
63
+ reason_text: 'Update this documentation',
64
+ }));
65
+
66
+ const headers = receivedInit?.headers as Record<string, string>;
67
+ assert.deepEqual(headers, {
68
+ 'Content-Type': 'application/json',
69
+ 'x-velu-site-host': 'docs.example.com',
70
+ });
71
+ });
72
+
73
+ test('submitPublicFeedback reports non-2xx responses as request_failed', async () => {
74
+ const result = await submitPublicFeedback({
75
+ pageUrl: 'https://docs.example.com/page',
76
+ helpful: true,
77
+ reasonText: 'Something else',
78
+ siteHost: 'docs.example.com',
79
+ fetchImpl: async () => new Response(null, { status: 500 }),
80
+ });
81
+
82
+ assert.deepEqual(result, { ok: false, reason: 'request_failed', status: 500 });
83
+ });
@@ -0,0 +1,89 @@
1
+ export const PUBLIC_FEEDBACK_ENDPOINT_PATH = '/api/v1/public/feedback';
2
+ export const PUBLIC_API_BASE_URL = 'https://api.getvelu.com';
3
+
4
+ export interface PublicFeedbackPayload {
5
+ page_url: string;
6
+ helpful: boolean;
7
+ reason_text: string;
8
+ details?: string;
9
+ email?: string;
10
+ }
11
+
12
+ interface BuildPayloadInput {
13
+ pageUrl: string;
14
+ helpful: boolean;
15
+ reasonText: string;
16
+ details?: string;
17
+ email?: string;
18
+ }
19
+
20
+ export interface SubmitPublicFeedbackInput extends BuildPayloadInput {
21
+ siteHost: string;
22
+ fetchImpl?: typeof fetch;
23
+ }
24
+
25
+ type SubmitErrorReason = 'invalid_payload' | 'request_failed' | 'network_error';
26
+
27
+ export type SubmitPublicFeedbackResult =
28
+ | { ok: true }
29
+ | { ok: false; reason: SubmitErrorReason; status?: number };
30
+
31
+ function trimOptional(value: string | undefined): string | undefined {
32
+ if (typeof value !== 'string') return undefined;
33
+ const trimmed = value.trim();
34
+ return trimmed.length > 0 ? trimmed : undefined;
35
+ }
36
+
37
+ export function resolvePublicFeedbackEndpoint(): string {
38
+ return `${PUBLIC_API_BASE_URL}${PUBLIC_FEEDBACK_ENDPOINT_PATH}`;
39
+ }
40
+
41
+ export function buildPublicFeedbackPayload(input: BuildPayloadInput): PublicFeedbackPayload | null {
42
+ const pageUrl = trimOptional(input.pageUrl);
43
+ const reasonText = trimOptional(input.reasonText);
44
+ if (!pageUrl || !reasonText) return null;
45
+
46
+ const payload: PublicFeedbackPayload = {
47
+ page_url: pageUrl,
48
+ helpful: input.helpful,
49
+ reason_text: reasonText,
50
+ };
51
+
52
+ const details = trimOptional(input.details);
53
+ if (details) payload.details = details;
54
+
55
+ const email = trimOptional(input.email);
56
+ if (email) payload.email = email;
57
+
58
+ return payload;
59
+ }
60
+
61
+ export async function submitPublicFeedback(input: SubmitPublicFeedbackInput): Promise<SubmitPublicFeedbackResult> {
62
+ const payload = buildPublicFeedbackPayload(input);
63
+ if (!payload) return { ok: false, reason: 'invalid_payload' };
64
+
65
+ const fetchImpl = input.fetchImpl ?? fetch;
66
+ try {
67
+ const response = await fetchImpl(resolvePublicFeedbackEndpoint(), {
68
+ method: 'POST',
69
+ credentials: 'include',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'x-velu-site-host': input.siteHost,
73
+ },
74
+ body: JSON.stringify(payload),
75
+ });
76
+
77
+ if (!response.ok) {
78
+ return {
79
+ ok: false,
80
+ reason: 'request_failed',
81
+ status: response.status,
82
+ };
83
+ }
84
+
85
+ return { ok: true };
86
+ } catch {
87
+ return { ok: false, reason: 'network_error' };
88
+ }
89
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, type MouseEvent as ReactMouseEvent } from 'react';
4
4
  import { ThumbsDown, ThumbsUp } from 'lucide-react';
5
+ import { submitPublicFeedback } from '@/components/page-feedback-api';
5
6
 
6
7
  type Vote = 'yes' | 'no';
7
8
 
@@ -26,10 +27,13 @@ export function PageFeedback() {
26
27
  const [selectedReason, setSelectedReason] = useState<string>('');
27
28
  const [details, setDetails] = useState('');
28
29
  const [email, setEmail] = useState('');
30
+ const [isSubmitting, setIsSubmitting] = useState(false);
31
+ const [submitError, setSubmitError] = useState(false);
29
32
 
30
33
  const showForm = vote !== null;
31
34
  const options = vote === 'yes' ? YES_OPTIONS : NO_OPTIONS;
32
35
  const showOptionalInputs = selectedReason === 'Something else';
36
+ const canSubmit = vote !== null && selectedReason.trim().length > 0 && !isSubmitting;
33
37
 
34
38
  const onChooseVote = (value: Vote) => {
35
39
  if (vote === value) return;
@@ -37,6 +41,7 @@ export function PageFeedback() {
37
41
  setSelectedReason('');
38
42
  setDetails('');
39
43
  setEmail('');
44
+ setSubmitError(false);
40
45
  };
41
46
 
42
47
  const stopEvent = (event: ReactMouseEvent) => {
@@ -49,11 +54,32 @@ export function PageFeedback() {
49
54
  setSelectedReason('');
50
55
  setDetails('');
51
56
  setEmail('');
57
+ setIsSubmitting(false);
58
+ setSubmitError(false);
52
59
  };
53
60
 
54
- const onSubmit = () => {
55
- // Placeholder for analytics/reporting integration.
56
- onCancel();
61
+ const onSubmit = async () => {
62
+ if (!vote || !selectedReason.trim() || isSubmitting) return;
63
+
64
+ setIsSubmitting(true);
65
+ setSubmitError(false);
66
+
67
+ const result = await submitPublicFeedback({
68
+ helpful: vote === 'yes',
69
+ reasonText: selectedReason,
70
+ details: showOptionalInputs ? details : undefined,
71
+ email: showOptionalInputs ? email : undefined,
72
+ pageUrl: window.location.href,
73
+ siteHost: window.location.host,
74
+ });
75
+
76
+ if (result.ok) {
77
+ onCancel();
78
+ return;
79
+ }
80
+
81
+ setSubmitError(true);
82
+ setIsSubmitting(false);
57
83
  };
58
84
 
59
85
  return (
@@ -65,6 +91,7 @@ export function PageFeedback() {
65
91
  type="button"
66
92
  className={['velu-page-feedback-btn', vote === 'yes' ? 'is-active' : ''].filter(Boolean).join(' ')}
67
93
  aria-label="Mark page as helpful"
94
+ disabled={isSubmitting}
68
95
  onClick={(event) => {
69
96
  stopEvent(event);
70
97
  onChooseVote('yes');
@@ -77,6 +104,7 @@ export function PageFeedback() {
77
104
  type="button"
78
105
  className={['velu-page-feedback-btn', vote === 'no' ? 'is-active' : ''].filter(Boolean).join(' ')}
79
106
  aria-label="Mark page as not helpful"
107
+ disabled={isSubmitting}
80
108
  onClick={(event) => {
81
109
  stopEvent(event);
82
110
  onChooseVote('no');
@@ -103,10 +131,12 @@ export function PageFeedback() {
103
131
  type="button"
104
132
  role="radio"
105
133
  aria-checked={checked}
134
+ disabled={isSubmitting}
106
135
  className={['velu-page-feedback-option', checked ? 'is-checked' : ''].filter(Boolean).join(' ')}
107
136
  onClick={(event) => {
108
137
  stopEvent(event);
109
138
  setSelectedReason(option);
139
+ setSubmitError(false);
110
140
  }}
111
141
  >
112
142
  <span className="velu-page-feedback-radio" aria-hidden="true" />
@@ -123,6 +153,7 @@ export function PageFeedback() {
123
153
  rows={3}
124
154
  placeholder="(Optional) Could you share more about your experience?"
125
155
  value={details}
156
+ disabled={isSubmitting}
126
157
  onChange={(event) => setDetails(event.target.value)}
127
158
  />
128
159
  <input
@@ -130,6 +161,7 @@ export function PageFeedback() {
130
161
  type="email"
131
162
  placeholder="(Optional) Email"
132
163
  value={email}
164
+ disabled={isSubmitting}
133
165
  onChange={(event) => setEmail(event.target.value)}
134
166
  />
135
167
  </div>
@@ -139,6 +171,7 @@ export function PageFeedback() {
139
171
  <button
140
172
  type="button"
141
173
  className="velu-page-feedback-cancel"
174
+ disabled={isSubmitting}
142
175
  onClick={(event) => {
143
176
  stopEvent(event);
144
177
  onCancel();
@@ -149,9 +182,12 @@ export function PageFeedback() {
149
182
  <button
150
183
  type="button"
151
184
  className="velu-page-feedback-submit"
185
+ disabled={!canSubmit}
186
+ aria-busy={isSubmitting}
187
+ title={submitError ? 'Unable to submit feedback right now. Please try again.' : undefined}
152
188
  onClick={(event) => {
153
189
  stopEvent(event);
154
- onSubmit();
190
+ void onSubmit();
155
191
  }}
156
192
  >
157
193
  Submit feedback
@@ -173,6 +173,18 @@ interface VeluContextualCustomOption {
173
173
  href: string;
174
174
  }
175
175
 
176
+ interface VeluFontDef {
177
+ family: string;
178
+ weight?: number;
179
+ source?: string;
180
+ format?: 'woff' | 'woff2';
181
+ }
182
+
183
+ export interface VeluResolvedFonts {
184
+ heading?: VeluFontDef;
185
+ body?: VeluFontDef;
186
+ }
187
+
176
188
  interface VeluConfig {
177
189
  name?: string;
178
190
  description?: string;
@@ -188,6 +200,7 @@ interface VeluConfig {
188
200
  library?: string;
189
201
  };
190
202
  appearance?: 'system' | 'light' | 'dark';
203
+ fonts?: VeluFontDef | { heading?: VeluFontDef; body?: VeluFontDef };
191
204
  languages?: string[];
192
205
  openapi?: string | string[] | Record<string, unknown>;
193
206
  asyncapi?: string | string[] | Record<string, unknown>;
@@ -691,6 +704,19 @@ export function getAppearance(): 'system' | 'light' | 'dark' {
691
704
 
692
705
  export type VeluIconLibrary = 'fontawesome' | 'lucide' | 'tabler';
693
706
 
707
+ export function getFontsConfig(): VeluResolvedFonts | null {
708
+ const raw = loadVeluConfig().fonts;
709
+ if (!raw) return null;
710
+ // Single font definition (has 'family') → apply to both heading and body
711
+ if ('family' in raw && typeof raw.family === 'string') {
712
+ return { heading: raw as VeluFontDef, body: raw as VeluFontDef };
713
+ }
714
+ // Separate heading/body
715
+ const obj = raw as { heading?: VeluFontDef; body?: VeluFontDef };
716
+ if (!obj.heading && !obj.body) return null;
717
+ return { heading: obj.heading, body: obj.body };
718
+ }
719
+
694
720
  export function getIconLibrary(): VeluIconLibrary {
695
721
  const raw = loadVeluConfig().icons?.library;
696
722
  if (raw === 'lucide' || raw === 'tabler' || raw === 'fontawesome') return raw;
@@ -152,9 +152,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
152
152
  ...props
153
153
  }: any) => {
154
154
  const hasImage = Boolean(img || image);
155
- const hasIcon = Boolean(icon);
156
- // Mintlify-like default: icon cards are horizontal unless explicitly disabled.
157
- const useHorizontal = typeof horizontal === 'boolean' ? horizontal : (hasIcon && !hasImage);
155
+ const useHorizontal = horizontal === true;
158
156
 
159
157
  return (
160
158
  <Card
@@ -65,7 +65,6 @@ export interface VeluConfig {
65
65
  theme?: string;
66
66
  colors?: { primary?: string; light?: string; dark?: string };
67
67
  appearance?: 'system' | 'light' | 'dark';
68
- styling?: { codeblocks?: { theme?: string | { light: string; dark: string } } };
69
68
  navigation: {
70
69
  tabs?: VeluTab[];
71
70
  anchors?: VeluAnchor[];
package/src/themes.ts CHANGED
@@ -6,17 +6,23 @@ interface VeluColors {
6
6
  dark?: string;
7
7
  }
8
8
 
9
- interface VeluStyling {
10
- codeblocks?: {
11
- theme?: string | { light: string; dark: string };
12
- };
9
+ interface VeluFontDef {
10
+ family: string;
11
+ weight?: number;
12
+ source?: string;
13
+ format?: "woff" | "woff2";
14
+ }
15
+
16
+ interface VeluFontsConfig {
17
+ heading?: VeluFontDef;
18
+ body?: VeluFontDef;
13
19
  }
14
20
 
15
21
  interface ThemeConfig {
16
22
  theme?: string;
17
23
  colors?: VeluColors;
18
24
  appearance?: "system" | "light" | "dark";
19
- styling?: VeluStyling;
25
+ fonts?: VeluFontsConfig;
20
26
  }
21
27
 
22
28
  const FUMADOCS_THEMES = [
@@ -160,6 +166,46 @@ function generateThemeCss(config: ThemeConfig): string {
160
166
  }
161
167
  lines.push("");
162
168
 
169
+ // Font configuration
170
+ if (config.fonts) {
171
+ const { heading, body } = config.fonts;
172
+ // @font-face declarations for custom sources
173
+ for (const def of [heading, body].filter(Boolean) as VeluFontDef[]) {
174
+ if (def.source) {
175
+ const fmt = def.format || (def.source.endsWith(".woff2") ? "woff2" : "woff");
176
+ lines.push(`@font-face {`);
177
+ lines.push(` font-family: '${def.family}';`);
178
+ lines.push(` src: url('${def.source}') format('${fmt}');`);
179
+ if (def.weight) lines.push(` font-weight: ${def.weight};`);
180
+ lines.push(` font-display: swap;`);
181
+ lines.push(`}`);
182
+ lines.push("");
183
+ }
184
+ }
185
+ // CSS variable overrides
186
+ const vars: string[] = [];
187
+ if (body) {
188
+ vars.push(` --font-fd-sans: '${body.family}', ui-sans-serif, system-ui, sans-serif;`);
189
+ }
190
+ if (heading) {
191
+ vars.push(` --velu-font-heading: '${heading.family}', ui-sans-serif, system-ui, sans-serif;`);
192
+ }
193
+ if (vars.length) {
194
+ lines.push(":root {");
195
+ lines.push(...vars);
196
+ lines.push("}");
197
+ lines.push("");
198
+ }
199
+ // Heading font-family rule
200
+ if (heading) {
201
+ lines.push("h1, h2, h3, h4, h5, h6 {");
202
+ lines.push(` font-family: var(--velu-font-heading);`);
203
+ if (heading.weight) lines.push(` font-weight: ${heading.weight};`);
204
+ lines.push("}");
205
+ lines.push("");
206
+ }
207
+ }
208
+
163
209
  return lines.join("\n");
164
210
  }
165
211
 
@@ -169,4 +215,4 @@ function getThemeNames(): string[] {
169
215
 
170
216
  const THEMES = [...FUMADOCS_THEMES];
171
217
 
172
- export { generateThemeCss, getThemeNames, resolveThemeName, THEMES, ThemeConfig, VeluColors, VeluStyling };
218
+ export { generateThemeCss, getThemeNames, resolveThemeName, THEMES, ThemeConfig, VeluColors };
package/src/validate.ts CHANGED
@@ -113,6 +113,13 @@ interface VeluVersionNav {
113
113
 
114
114
  type VeluOpenApiSource = string | string[] | Record<string, unknown>;
115
115
 
116
+ interface VeluFontDefinition {
117
+ family: string;
118
+ weight?: number;
119
+ source?: string;
120
+ format?: "woff" | "woff2";
121
+ }
122
+
116
123
  interface VeluConfig {
117
124
  $schema?: string;
118
125
  variables?: Record<string, string>;
@@ -123,7 +130,8 @@ interface VeluConfig {
123
130
  theme?: string;
124
131
  colors?: { primary?: string; light?: string; dark?: string };
125
132
  appearance?: "system" | "light" | "dark";
126
- styling?: { codeblocks?: { theme?: string | { light: string; dark: string } } };
133
+
134
+ fonts?: VeluFontDefinition | { heading?: VeluFontDefinition; body?: VeluFontDefinition };
127
135
  openapi?: VeluOpenApiSource;
128
136
  asyncapi?: VeluOpenApiSource;
129
137
  api?: {