diff --git a/.changeset/six-laws-repair.md b/.changeset/six-laws-repair.md
new file mode 100644
index 000000000..5e7207c08
--- /dev/null
+++ b/.changeset/six-laws-repair.md
@@ -0,0 +1,5 @@
+---
+"@valbuild/ui": patch
+---
+
+Updated look and feel of left navigation
diff --git a/packages/ui/.storybook/preview.js b/packages/ui/.storybook/preview.js
index 29e7ac99f..24af334d0 100644
--- a/packages/ui/.storybook/preview.js
+++ b/packages/ui/.storybook/preview.js
@@ -1,4 +1,6 @@
+import React from "react";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
+import { TooltipProvider } from "../spa/components/designSystem/tooltip";
import "tailwindcss/tailwind.css";
import "../spa/index.css";
@@ -14,6 +16,11 @@ export const decorators = [
defaultTheme: "dark",
attributeName: "data-mode",
}),
+ // Many components (e.g. the NavMenu's error badges) use Radix Tooltip
+ // which requires a TooltipProvider ancestor. The live app mounts one in
+ // `ValProvider`; Storybook needs its own.
+ (Story) =>
+ React.createElement(TooltipProvider, null, React.createElement(Story)),
];
/** @type { import('@storybook/react').Preview } */
diff --git a/packages/ui/spa/components/NavMenu/ErrorBadge.tsx b/packages/ui/spa/components/NavMenu/ErrorBadge.tsx
new file mode 100644
index 000000000..f8442b0ea
--- /dev/null
+++ b/packages/ui/spa/components/NavMenu/ErrorBadge.tsx
@@ -0,0 +1,105 @@
+import { ReactNode } from "react";
+import { cn } from "../designSystem/cn";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "../designSystem/tooltip";
+
+export type ErrorBadgeProps = {
+ /** Total error count to display in the badge. */
+ count: number;
+ /** Number that resolve to this row itself (vs. descendants). Drives the
+ * tooltip wording. */
+ ownCount: number;
+ /** First error message — shown verbatim when ownCount > 0. */
+ firstMessage?: string;
+ /** Force the badge to render with smaller height (used in nested rows). */
+ size?: "sm" | "md";
+ /** Optional element wrapping the badge inside the tooltip trigger, for
+ * cases where the badge sits inside a button row. */
+ asChild?: boolean;
+ /** Optional override className for the badge element. */
+ className?: string;
+};
+
+/**
+ * The red pill used everywhere in the nav menu to indicate validation
+ * errors. Tooltip on hover/focus shows the first error message (for own
+ * errors) or an aggregate hint (for descendants).
+ *
+ * Relies on the TooltipProvider mounted in `ValProvider`.
+ */
+export function ErrorBadge({
+ count,
+ ownCount,
+ firstMessage,
+ size = "sm",
+ className,
+}: ErrorBadgeProps) {
+ if (count <= 0) return null;
+ const label = formatCount(count);
+ const tip = buildTooltip({ count, ownCount, firstMessage });
+ return (
+
+
+
+ {label}
+
+
+
+ {tip}
+
+
+ );
+}
+
+function formatCount(count: number): ReactNode {
+ if (count > 99) return "99+";
+ return count;
+}
+
+function buildTooltip({
+ count,
+ ownCount,
+ firstMessage,
+}: {
+ count: number;
+ ownCount: number;
+ firstMessage?: string;
+}): ReactNode {
+ if (ownCount > 0 && firstMessage) {
+ if (ownCount === 1 && count === 1) {
+ return {firstMessage};
+ }
+ return (
+
+ {firstMessage}
+
+ {count === 1
+ ? "1 error"
+ : ownCount === count
+ ? `${count} errors`
+ : `${count} errors total (${ownCount} here)`}
+
+
+ );
+ }
+ // Aggregate-only: descendants carry the errors.
+ return {count === 1 ? "1 error below" : `${count} errors below`};
+}
diff --git a/packages/ui/spa/components/NavMenu/ExplorerItem.tsx b/packages/ui/spa/components/NavMenu/ExplorerItem.tsx
index 1e4612fc3..574a5a910 100644
--- a/packages/ui/spa/components/NavMenu/ExplorerItem.tsx
+++ b/packages/ui/spa/components/NavMenu/ExplorerItem.tsx
@@ -1,9 +1,10 @@
-import { ChevronRight, Folder, FileText, AlertCircle } from "lucide-react";
+import { ChevronRight } from "lucide-react";
import { useState, useMemo } from "react";
import { cn } from "../designSystem/cn";
import { AnimateHeight } from "../AnimateHeight";
import { ExplorerItem as ExplorerItemType } from "./types";
-import { prettifyFilename } from "../../utils/prettifyFilename";
+import { ErrorBadge } from "./ErrorBadge";
+import { totalExplorerErrorCount } from "./errorAggregation";
export type ExplorerItemProps = {
item: ExplorerItemType;
@@ -27,22 +28,16 @@ export function ExplorerItemNode({
const isActive = currentPath.startsWith(item.fullPath) && !item.isDirectory;
const isActiveParent =
currentPath.startsWith(item.fullPath) && item.isDirectory;
+ const isExactActive = currentPath === item.fullPath;
- // Check if any children have errors (for collapsed indicator)
- const hasDescendantError = useMemo(() => {
- if (item.hasError) return true;
- const checkChildren = (children: ExplorerItemType[]): boolean => {
- return children.some(
- (child) => child.hasError || checkChildren(child.children),
- );
- };
- return checkChildren(item.children);
- }, [item]);
-
- const showErrorIndicator = item.hasError || (!isOpen && hasDescendantError);
+ // Errors that resolve directly to this file (zero for directories), plus
+ // the recursive total used by the count badge.
+ const ownErrorCount = item.errors?.ownCount ?? (item.hasError ? 1 : 0);
+ const totalErrorCount = useMemo(() => totalExplorerErrorCount(item), [item]);
+ const hasOwnError = ownErrorCount > 0;
+ // Sort: directories first, then alphabetically.
const sortedChildren = useMemo(() => {
- // Sort: directories first, then alphabetically
return [...item.children].sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
@@ -50,15 +45,11 @@ export function ExplorerItemNode({
});
}, [item.children]);
- const handleClick = () => {
- if (item.isDirectory) {
- setIsOpen(!isOpen);
- } else if (onNavigate) {
- onNavigate(item.fullPath);
- }
- };
+ // Strip val module suffixes — `.val.ts`/`.val.js` are noise on every row.
+ // Other extensions are kept so users can still tell different file types apart.
+ const displayName = useMemo(() => stripValExtension(item.name), [item.name]);
- // Skip root node rendering
+ // Skip the synthetic "/" root row — just render its children at depth 0.
if (item.name === "/" && depth === 0) {
return (
<>
@@ -75,57 +66,78 @@ export function ExplorerItemNode({
);
}
+ const handleClick = () => {
+ if (item.isDirectory) {
+ setIsOpen((prev) => !prev);
+ } else if (onNavigate) {
+ onNavigate(item.fullPath);
+ }
+ };
+
+ const indent = depth * 12 + 8;
+
return (
-
- {showErrorIndicator && (
-
+ {totalErrorCount > 0 && (
+
)}
-
+
{hasChildren && (
@@ -145,3 +157,14 @@ export function ExplorerItemNode({
);
}
+
+/**
+ * Strip the val module extensions (`.val.ts`, `.val.js`) so the display reads
+ * as a clean module name. Other extensions are kept so users can still
+ * distinguish file types.
+ */
+function stripValExtension(name: string): string {
+ if (name.endsWith(".val.ts")) return name.slice(0, -".val.ts".length);
+ if (name.endsWith(".val.js")) return name.slice(0, -".val.js".length);
+ return name;
+}
diff --git a/packages/ui/spa/components/NavMenu/ExplorerSection.tsx b/packages/ui/spa/components/NavMenu/ExplorerSection.tsx
index 291282d6b..339ee4fc8 100644
--- a/packages/ui/spa/components/NavMenu/ExplorerSection.tsx
+++ b/packages/ui/spa/components/NavMenu/ExplorerSection.tsx
@@ -1,4 +1,5 @@
import { PanelsTopLeft } from "lucide-react";
+import { useMemo } from "react";
import { cn } from "../designSystem/cn";
import { ExplorerItem } from "./types";
import { ExplorerItemNode } from "./ExplorerItem";
@@ -8,6 +9,8 @@ import {
AccordionTrigger,
} from "../designSystem/accordion";
import { ScrollArea } from "../designSystem/scroll-area";
+import { ErrorBadge } from "./ErrorBadge";
+import { totalExplorerErrorCount } from "./errorAggregation";
export type ExplorerSectionProps = {
/** Explorer data */
@@ -26,6 +29,11 @@ export function ExplorerSection({
onNavigate,
maxHeight = "100%",
}: ExplorerSectionProps) {
+ const sectionErrorCount = useMemo(
+ () => totalExplorerErrorCount(explorer),
+ [explorer],
+ );
+
return (
Explorer
+ {sectionErrorCount > 0 && (
+
+ )}
diff --git a/packages/ui/spa/components/NavMenu/NewPageForm.tsx b/packages/ui/spa/components/NavMenu/NewPageForm.tsx
new file mode 100644
index 000000000..8b689e965
--- /dev/null
+++ b/packages/ui/spa/components/NavMenu/NewPageForm.tsx
@@ -0,0 +1,404 @@
+import { useEffect, useMemo, useState } from "react";
+import { ModuleFilePath } from "@valbuild/core";
+import { RoutePattern } from "@valbuild/shared/internal";
+import { cn } from "../designSystem/cn";
+import { Button } from "../designSystem/button";
+
+/**
+ * A route a user can create new pages under.
+ */
+export type AvailableRoute = {
+ moduleFilePath: ModuleFilePath;
+ routePattern: RoutePattern[];
+ /** Human-readable pattern, e.g. "/blogs/[blog]". */
+ patternString: string;
+ /** Existing URL paths that would collide if reused. */
+ existingKeys: string[];
+};
+
+export type NewPageFormProps = {
+ /** One or more routes the user can create pages under. */
+ routes: AvailableRoute[];
+ /** Called with the moduleFilePath and the fully built URL path. */
+ onSubmit: (moduleFilePath: ModuleFilePath, urlPath: string) => void;
+ /** Called when the user dismisses the form. */
+ onCancel: () => void;
+};
+
+/**
+ * Form for creating a new page in the sitemap.
+ *
+ * Supports:
+ * - single dynamic segment routes (`/blogs/[blog]`)
+ * - multi-segment routes (`/products/[category]/[product]`)
+ * - catch-all routes (`/docs/[...slug]`)
+ *
+ * Visual treatment matches the redesigned sitemap: the static parts of the route
+ * are rendered as non-editable chips, dynamic parts as inputs.
+ */
+export function NewPageForm({ routes, onSubmit, onCancel }: NewPageFormProps) {
+ const [selectedRouteKey, setSelectedRouteKey] = useState(() =>
+ routeKey(routes[0]),
+ );
+ const selectedRoute = useMemo(
+ () =>
+ routes.find((r) => routeKey(r) === selectedRouteKey) ?? routes[0] ?? null,
+ [routes, selectedRouteKey],
+ );
+
+ const [paramsByRoute, setParamsByRoute] = useState<
+ Record>
+ >({});
+ const [errorsByRoute, setErrorsByRoute] = useState<
+ Record>
+ >({});
+
+ const params = selectedRoute
+ ? (paramsByRoute[routeKey(selectedRoute)] ?? {})
+ : {};
+ const errors = selectedRoute
+ ? (errorsByRoute[routeKey(selectedRoute)] ?? {})
+ : {};
+
+ const fullPath = useMemo(() => {
+ if (!selectedRoute) return "";
+ return buildFullPath(selectedRoute.routePattern, params);
+ }, [selectedRoute, params]);
+
+ const isComplete = useMemo(() => {
+ if (!selectedRoute) return false;
+ return selectedRoute.routePattern.every((part) => {
+ if (part.type === "string-param" || part.type === "array-param") {
+ return !!params[part.paramName] && !errors[part.paramName];
+ }
+ return true;
+ });
+ }, [selectedRoute, params, errors]);
+
+ const alreadyExists =
+ !!selectedRoute && selectedRoute.existingKeys.includes(fullPath);
+ const disabled = !isComplete || alreadyExists;
+
+ // Focus the first dynamic input when the selected route changes.
+ // We rely on autoFocus on render, so changing the key forces remount.
+ const inputRenderKey = selectedRoute ? routeKey(selectedRoute) : "";
+
+ if (!selectedRoute) {
+ return (
+
+ No routes accept new pages.
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function routeKey(route: AvailableRoute | undefined): string {
+ if (!route) return "";
+ return `${route.moduleFilePath}::${route.patternString}`;
+}
+
+/**
+ * Build the full URL path from a route pattern and the user-filled params.
+ * Mirrors the behavior of the old AddRouteForm.
+ */
+function buildFullPath(
+ pattern: RoutePattern[],
+ params: Record,
+): string {
+ return (
+ "/" +
+ pattern
+ .map((part) => {
+ if (part.type === "string-param" || part.type === "array-param") {
+ return params[part.paramName] || "";
+ }
+ return part.name;
+ })
+ .join("/")
+ );
+}
+
+/**
+ * Render the route pattern as a non-editable display, with dynamic
+ * segments shown as purple pills.
+ */
+function RoutePatternDisplay({ pattern }: { pattern: RoutePattern[] }) {
+ return (
+
+ /
+ {pattern.map((part, i) => (
+
+ {part.type === "literal" ? (
+ {part.name}
+ ) : (
+
+ )}
+ {i < pattern.length - 1 && (
+ /
+ )}
+
+ ))}
+
+ );
+}
+
+/**
+ * Render the route pattern as a mix of static prefix chips and editable
+ * inputs for dynamic segments.
+ */
+function RoutePatternInputs({
+ pattern,
+ params,
+ errors,
+ onChange,
+}: {
+ pattern: RoutePattern[];
+ params: Record;
+ errors: Record;
+ onChange: (
+ paramName: string,
+ value: string,
+ error: string | undefined,
+ ) => void;
+}) {
+ // Group adjacent literals so they render as a single chip like "/blogs/".
+ const groups = useMemo(() => groupPattern(pattern), [pattern]);
+ const firstDynamicIndex = useMemo(
+ () => groups.findIndex((g) => g.type !== "literal-run"),
+ [groups],
+ );
+
+ return (
+
+ {groups.map((group, i) => {
+ if (group.type === "literal-run") {
+ return (
+
+ /{group.parts.map((p) => p.name).join("/")}
+ {i < groups.length - 1 ? "/" : ""}
+
+ );
+ }
+
+ const part = group.part;
+ const value = params[part.paramName] || "";
+ const error = errors[part.paramName];
+ const isCatchAll = part.type === "array-param";
+
+ return (
+
+ {
+ const next = e.target.value;
+ const compare = isCatchAll ? next.replace(/\//g, "") : next;
+ const invalid =
+ next.length > 0 && encodeURIComponent(compare) !== compare;
+ onChange(
+ part.paramName,
+ next,
+ invalid ? "Invalid characters" : undefined,
+ );
+ }}
+ />
+ {error && (
+ {error}
+ )}
+
+ );
+ })}
+
+ );
+}
+
+type PatternGroup =
+ | { type: "literal-run"; parts: { name: string }[] }
+ | {
+ type: "dynamic";
+ part: Extract<
+ RoutePattern,
+ { type: "string-param" } | { type: "array-param" }
+ >;
+ };
+
+function groupPattern(pattern: RoutePattern[]): PatternGroup[] {
+ const groups: PatternGroup[] = [];
+ let currentRun: { name: string }[] | null = null;
+ for (const part of pattern) {
+ if (part.type === "literal") {
+ if (!currentRun) {
+ currentRun = [];
+ groups.push({ type: "literal-run", parts: currentRun });
+ }
+ currentRun.push({ name: part.name });
+ } else {
+ currentRun = null;
+ groups.push({ type: "dynamic", part });
+ }
+ }
+ return groups;
+}
+
+function DynamicSegmentPill({
+ part,
+}: {
+ part: Extract<
+ RoutePattern,
+ { type: "string-param" } | { type: "array-param" }
+ >;
+}) {
+ const label =
+ part.type === "array-param" ? `...${part.paramName}` : part.paramName;
+ return (
+
+ [{label}]
+
+ );
+}
+
+function RouteHint({ pattern }: { pattern: RoutePattern[] }) {
+ const hasCatchAll = pattern.some((p) => p.type === "array-param");
+ const dynamicCount = pattern.filter(
+ (p) => p.type === "string-param" || p.type === "array-param",
+ ).length;
+
+ if (hasCatchAll) {
+ return (
+
+ Catch-all segments can contain / for nested paths.
+
+ );
+ }
+ if (dynamicCount > 1) {
+ return (
+
+ Fill in each segment. Slashes will be added automatically.
+
+ );
+ }
+ return (
+
+ Use lowercase letters, numbers, and hyphens.
+
+ );
+}
+
+/**
+ * Native select used for picking the route when multiple are available.
+ * Kept simple — the Radix Select needs a portal container and complicates the
+ * popover. A native select is a reasonable default for the count of routes
+ * a project will typically have.
+ */
+function RouteSelect({
+ routes,
+ value,
+ onChange,
+}: {
+ routes: AvailableRoute[];
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ // Force the selected route to be valid if `routes` changes.
+ useEffect(() => {
+ if (!routes.find((r) => routeKey(r) === value) && routes[0]) {
+ onChange(routeKey(routes[0]));
+ }
+ }, [routes, value, onChange]);
+
+ return (
+
+ );
+}
diff --git a/packages/ui/spa/components/NavMenu/SitemapItem.tsx b/packages/ui/spa/components/NavMenu/SitemapItem.tsx
index 90203a2c2..08f2fb6e7 100644
--- a/packages/ui/spa/components/NavMenu/SitemapItem.tsx
+++ b/packages/ui/spa/components/NavMenu/SitemapItem.tsx
@@ -1,11 +1,7 @@
-import {
- AlertCircle,
- ChevronRight,
- FileText,
- Folder,
- Plus,
-} from "lucide-react";
+import { ChevronRight, Plus } from "lucide-react";
import { useState, useMemo, useRef, useEffect } from "react";
+import { ModuleFilePath } from "@valbuild/core";
+import { RoutePattern } from "@valbuild/shared/internal";
import { cn } from "../designSystem/cn";
import { AnimateHeight } from "../AnimateHeight";
import { SitemapItem as SitemapItemType } from "./types";
@@ -14,8 +10,9 @@ import {
PopoverContent,
PopoverTrigger,
} from "../designSystem/popover";
-import { RoutePattern } from "@valbuild/shared/internal";
-import { Button } from "../designSystem/button";
+import { AvailableRoute, NewPageForm } from "./NewPageForm";
+import { ErrorBadge } from "./ErrorBadge";
+import { totalSitemapErrorCount } from "./errorAggregation";
export type SitemapItemProps = {
item: SitemapItemType;
@@ -25,8 +22,12 @@ export type SitemapItemProps = {
onNavigate?: (sourcePath: string) => void;
/** Called when a new page should be added */
onAddPage?: (moduleFilePath: string, urlPath: string) => void;
- /** Nesting depth for indentation */
+ /** Existing URL paths (for duplicate validation in row-level add). */
+ existingUrls?: string[];
+ /** Nesting depth, used to indent the row */
depth?: number;
+ /** Accumulated URL path from ancestors (e.g. "/blogs"). */
+ parentPath?: string;
/** Container for portal (for popover) */
portalContainer?: HTMLElement | null;
};
@@ -36,7 +37,9 @@ export function SitemapItemNode({
currentPath = "",
onNavigate,
onAddPage,
+ existingUrls,
depth = 0,
+ parentPath = "",
portalContainer,
}: SitemapItemProps) {
const [isOpen, setIsOpen] = useState(true);
@@ -48,21 +51,48 @@ export function SitemapItemNode({
const isNavigable = !!item.sourcePath;
const isActive = item.sourcePath && currentPath.startsWith(item.sourcePath);
const isExactActive = item.sourcePath === currentPath;
- const isFolder = hasChildren && !isNavigable;
- const hasDescendantError = useMemo(() => {
- const check = (items: SitemapItemType[]): boolean =>
- items.some((c) => c.hasError || check(c.children));
- return check(item.children);
- }, [item.children]);
+ // The URL we render in this row. The root entry is just "/"; everything else
+ // is the parent path joined with this item's segment.
+ const displayUrl = useMemo(() => {
+ if (item.name === "/" || item.name === "") return "/";
+ return `${parentPath}/${item.name}`;
+ }, [item.name, parentPath]);
+
+ // The URL prefix from the parent — rendered in muted text so the segment for
+ // this row stands out. For root and top-level items there's no prefix.
+ const prefix = useMemo(() => {
+ if (displayUrl === "/") return "";
+ if (parentPath === "") return "/";
+ return `${parentPath}/`;
+ }, [displayUrl, parentPath]);
- const showErrorDot = item.hasError || (!isOpen && hasDescendantError);
+ const ownSegment = useMemo(() => {
+ if (displayUrl === "/") return "/";
+ return item.name;
+ }, [displayUrl, item.name]);
- // Scroll into view when this item becomes the exact active item
+ // The depth of this row in URL terms (root = 0, /blogs = 1, /blogs/x = 2).
+ // Used to find the next dynamic segment a user could create under this row.
+ const urlDepth = useMemo(() => {
+ if (displayUrl === "/") return 0;
+ return displayUrl.split("/").filter(Boolean).length;
+ }, [displayUrl]);
+
+ const nextDynamicSegment = useMemo(() => {
+ if (!item.canAddChild || !item.routePattern) return null;
+ const part = item.routePattern[urlDepth];
+ if (!part) return null;
+ if (part.type === "string-param" || part.type === "array-param") {
+ return part;
+ }
+ return null;
+ }, [item.canAddChild, item.routePattern, urlDepth]);
+
+ // Scroll the active row into view, slightly delayed so accordion/expand
+ // animations have time to settle.
useEffect(() => {
if (isExactActive && itemRef.current) {
- // Use a small delay to ensure the DOM is fully settled after navigation
- // and any accordion/animation changes have completed
const timeoutId = setTimeout(() => {
itemRef.current?.scrollIntoView({
behavior: "smooth",
@@ -82,109 +112,138 @@ export function SitemapItemNode({
if (item.sourcePath && onNavigate) {
onNavigate(item.sourcePath);
} else if (hasChildren) {
- setIsOpen((isOpen) => !isOpen);
+ setIsOpen((prev) => !prev);
}
};
- const handleAddSubmit = (urlPath: string) => {
- if (item.moduleFilePath && onAddPage) {
- onAddPage(item.moduleFilePath, urlPath);
+ const handleAddSubmit = (moduleFilePath: ModuleFilePath, urlPath: string) => {
+ if (onAddPage) {
+ onAddPage(moduleFilePath, urlPath);
setAddPopoverOpen(false);
}
};
+ // Errors for this row + everything beneath it. The data layer attaches
+ // `errors.ownCount`; the descendant sum is recomputed each render but cached
+ // by item identity (stable across renders from `useNavMenuData`).
+ const ownErrorCount = item.errors?.ownCount ?? 0;
+ const totalErrorCount = useMemo(() => totalSitemapErrorCount(item), [item]);
+
+ const rowRoute: AvailableRoute | null = useMemo(() => {
+ if (!item.canAddChild || !item.moduleFilePath || !item.routePattern) {
+ return null;
+ }
+ return {
+ moduleFilePath: item.moduleFilePath,
+ routePattern: item.routePattern,
+ patternString: routePatternToString(item.routePattern),
+ existingKeys: existingUrls ?? item.existingKeys ?? [],
+ };
+ }, [
+ item.canAddChild,
+ item.moduleFilePath,
+ item.routePattern,
+ item.existingKeys,
+ existingUrls,
+ ]);
+
+ // Indent the row using a fixed left padding scaled by depth.
+ const indent = depth * 12 + 8;
+
return (
setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
- {hasChildren && (
+ {hasChildren ? (
{
- setIsOpen((isOpen) => !isOpen);
- }}
- className="pr-1.5"
+ onClick={() => setIsOpen((prev) => !prev)}
+ className="shrink-0 mr-1 text-fg-secondary"
+ aria-label={isOpen ? "Collapse" : "Expand"}
>
+ ) : (
+
)}
+
- {!hasChildren && }
-
- {isFolder ? (
-
- ) : (
-
- )}
-
- /{item.name !== "/" ? item.name : ""}
+ {prefix && {prefix}}
+
+ {ownSegment}
+
+
+ {nextDynamicSegment && (
+
+ )}
- {showErrorDot && (
-
+ {totalErrorCount > 0 && (
+
)}
- {item.canAddChild &&
- item.routePattern &&
- (showActions || addPopoverOpen) && (
-
-
- {
- e.stopPropagation();
- setAddPopoverOpen(true);
- }}
- title="Add page"
- >
-
-
-
-
+
+ {
+ e.stopPropagation();
+ setAddPopoverOpen(true);
+ }}
+ title="Add page under this route"
+ aria-label="Add page under this route"
>
- setAddPopoverOpen(false)}
- />
-
-
- )}
+
+
+
+
+ setAddPopoverOpen(false)}
+ />
+
+
+ )}
{hasChildren && (
@@ -197,7 +256,9 @@ export function SitemapItemNode({
currentPath={currentPath}
onNavigate={onNavigate}
onAddPage={onAddPage}
+ existingUrls={existingUrls}
depth={depth + 1}
+ parentPath={displayUrl === "/" ? "" : displayUrl}
portalContainer={portalContainer}
/>
))}
@@ -208,129 +269,48 @@ export function SitemapItemNode({
);
}
-/**
- * Simplified route form for adding new pages.
- * This can work standalone without providers for Storybook.
- */
-function AddRouteForm({
- routePattern,
- existingKeys,
- onSubmit,
- onCancel,
+function DynamicSegmentPill({
+ part,
}: {
- routePattern: RoutePattern[];
- existingKeys: string[];
- onSubmit: (urlPath: string) => void;
- onCancel: () => void;
+ part: Extract<
+ RoutePattern,
+ { type: "string-param" } | { type: "array-param" }
+ >;
}) {
- const [params, setParams] = useState
>({});
- const [errors, setErrors] = useState>({});
-
- const fullPath = useMemo(() => {
- return (
- "/" +
- routePattern
- .map((part) => {
- if (part.type === "string-param" || part.type === "array-param") {
- return params[part.paramName] || "";
- }
- return part.name;
- })
- .join("/")
- );
- }, [routePattern, params]);
-
- const isComplete = useMemo(() => {
- return routePattern.every((part) => {
- if (part.type === "string-param" || part.type === "array-param") {
- return !!params[part.paramName] && !errors[part.paramName];
- }
- return true;
- });
- }, [routePattern, params, errors]);
-
- const alreadyExists = existingKeys.includes(fullPath);
- const disabled = !isComplete || alreadyExists;
-
+ const label =
+ part.type === "array-param" ? `...${part.paramName}` : part.paramName;
return (
-
+/**
+ * Stringify a parsed route pattern back into a human-readable form like
+ * "/blogs/[blog]". Used as a display label and as part of the route's unique
+ * key.
+ */
+export function routePatternToString(pattern: RoutePattern[]): string {
+ if (pattern.length === 0) return "/";
+ return (
+ "/" +
+ pattern
+ .map((part) => {
+ if (part.type === "literal") return part.name;
+ if (part.type === "string-param") return `[${part.paramName}]`;
+ return `[...${part.paramName}]`;
+ })
+ .join("/")
);
}
diff --git a/packages/ui/spa/components/NavMenu/SitemapSection.tsx b/packages/ui/spa/components/NavMenu/SitemapSection.tsx
index c7a6617e1..d0b3ea657 100644
--- a/packages/ui/spa/components/NavMenu/SitemapSection.tsx
+++ b/packages/ui/spa/components/NavMenu/SitemapSection.tsx
@@ -1,14 +1,23 @@
-import { Globe } from "lucide-react";
+import React, { useMemo, useState } from "react";
+import { Globe, Plus } from "lucide-react";
+import { ModuleFilePath } from "@valbuild/core";
import { cn } from "../designSystem/cn";
import { SitemapItem } from "./types";
-import { SitemapItemNode } from "./SitemapItem";
+import { SitemapItemNode, routePatternToString } from "./SitemapItem";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../designSystem/accordion";
-import React from "react";
import { ScrollArea, ScrollBar } from "../designSystem/scroll-area";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "../designSystem/popover";
+import { AvailableRoute, NewPageForm } from "./NewPageForm";
+import { ErrorBadge } from "./ErrorBadge";
+import { totalSitemapErrorCount } from "./errorAggregation";
export type SitemapSectionProps = {
/** Sitemap data */
@@ -33,20 +42,93 @@ export function SitemapSection({
maxHeight = "100%",
portalContainer,
}: SitemapSectionProps) {
+ const [newPageOpen, setNewPageOpen] = useState(false);
+
+ // All available routes the user can create pages under, plus the full set of
+ // existing URLs so the form can warn on duplicates regardless of which
+ // route is selected.
+ const { routes, existingUrls } = useMemo(
+ () => collectSitemapRoutes(sitemap),
+ [sitemap],
+ );
+
+ const sectionErrorCount = useMemo(
+ () => totalSitemapErrorCount(sitemap),
+ [sitemap],
+ );
+
+ const handleNewPageSubmit = (
+ moduleFilePath: ModuleFilePath,
+ urlPath: string,
+ ) => {
+ if (onAddPage) {
+ onAddPage(moduleFilePath, urlPath);
+ setNewPageOpen(false);
+ }
+ };
+
return (
-
+
+
+
+ Pages
+ {sectionErrorCount > 0 && (
+
+ )}
+
+
+
+ {routes.length > 0 && (
+
+
+
+ {
+ e.stopPropagation();
+ setNewPageOpen(true);
+ }}
+ onMouseDown={(e) => e.stopPropagation()}
+ title="Create a new page"
+ aria-label="Create a new page"
+ >
+
+ New page
+
+
+
+ ({
+ ...r,
+ existingKeys: existingUrls,
+ }))}
+ onSubmit={handleNewPageSubmit}
+ onCancel={() => setNewPageOpen(false)}
+ />
+
+
+
)}
- >
-
-
- Pages
-
-
+
+
{sitemap.sourcePath ? (
@@ -56,6 +138,7 @@ export function SitemapSection({
currentPath={currentPath}
onNavigate={onNavigate}
onAddPage={onAddPage}
+ existingUrls={existingUrls}
portalContainer={portalContainer}
/>
) : (
@@ -67,6 +150,7 @@ export function SitemapSection({
currentPath={currentPath}
onNavigate={onNavigate}
onAddPage={onAddPage}
+ existingUrls={existingUrls}
portalContainer={portalContainer}
/>
))}
@@ -78,3 +162,48 @@ export function SitemapSection({
);
}
+
+/**
+ * Walk the sitemap collecting every unique route that accepts new pages, plus
+ * the flat list of every URL currently in the tree (used for duplicate
+ * validation in the top-level "New page" form).
+ */
+function collectSitemapRoutes(sitemap: SitemapItem): {
+ routes: AvailableRoute[];
+ existingUrls: string[];
+} {
+ const routes = new Map();
+ const urls: string[] = [];
+
+ function walk(item: SitemapItem, parentPath: string) {
+ const displayUrl =
+ item.name === "/" || item.name === ""
+ ? "/"
+ : `${parentPath}/${item.name}`;
+
+ if (item.sourcePath || item.children.length === 0) {
+ urls.push(displayUrl);
+ }
+
+ if (item.canAddChild && item.moduleFilePath && item.routePattern) {
+ const patternString = routePatternToString(item.routePattern);
+ const key = `${item.moduleFilePath}::${patternString}`;
+ if (!routes.has(key)) {
+ routes.set(key, {
+ moduleFilePath: item.moduleFilePath,
+ routePattern: item.routePattern,
+ patternString,
+ existingKeys: [],
+ });
+ }
+ }
+
+ for (const child of item.children) {
+ walk(child, displayUrl === "/" ? "" : displayUrl);
+ }
+ }
+
+ walk(sitemap, "");
+
+ return { routes: Array.from(routes.values()), existingUrls: urls };
+}
diff --git a/packages/ui/spa/components/NavMenu/errorAggregation.ts b/packages/ui/spa/components/NavMenu/errorAggregation.ts
new file mode 100644
index 000000000..a68653ba4
--- /dev/null
+++ b/packages/ui/spa/components/NavMenu/errorAggregation.ts
@@ -0,0 +1,33 @@
+import { ExplorerItem, SitemapItem } from "./types";
+
+/**
+ * Recursively sum the validation errors under a sitemap item, including its
+ * own. Used to drive the count badge on every row and the total in the
+ * "Pages" section header.
+ *
+ * The result is stable across renders as long as the input `item` reference
+ * is stable — `useNavMenuData` memoizes the tree, so calling this from a
+ * `useMemo([item])` is cheap in practice.
+ */
+export function totalSitemapErrorCount(item: SitemapItem): number {
+ let count = item.errors?.ownCount ?? 0;
+ for (const child of item.children) {
+ count += totalSitemapErrorCount(child);
+ }
+ return count;
+}
+
+/**
+ * Recursively sum the validation errors under an explorer item.
+ *
+ * Directories don't contribute their own errors. Falls back to the legacy
+ * boolean `hasError` for callers that haven't moved to the new `errors`
+ * shape (mockData primarily).
+ */
+export function totalExplorerErrorCount(item: ExplorerItem): number {
+ let count = item.errors?.ownCount ?? (item.hasError ? 1 : 0);
+ for (const child of item.children) {
+ count += totalExplorerErrorCount(child);
+ }
+ return count;
+}
diff --git a/packages/ui/spa/components/NavMenu/index.ts b/packages/ui/spa/components/NavMenu/index.ts
index e9e8613fc..39f694d15 100644
--- a/packages/ui/spa/components/NavMenu/index.ts
+++ b/packages/ui/spa/components/NavMenu/index.ts
@@ -6,5 +6,8 @@ export * from "./ExplorerSection";
export * from "./ExternalButton";
export * from "./SitemapItem";
export * from "./ExplorerItem";
+export * from "./NewPageForm";
+export * from "./ErrorBadge";
+export * from "./errorAggregation";
export * from "./useNavMenuData";
export * from "./mockData";
diff --git a/packages/ui/spa/components/NavMenu/mockData.ts b/packages/ui/spa/components/NavMenu/mockData.ts
index b878f349a..0c23172a9 100644
--- a/packages/ui/spa/components/NavMenu/mockData.ts
+++ b/packages/ui/spa/components/NavMenu/mockData.ts
@@ -15,6 +15,23 @@ const blogsRoutePattern: RoutePattern[] = [
{ type: "string-param", paramName: "blog", optional: false },
];
+/**
+ * Mock route pattern for /docs/[...slug] — a catch-all route.
+ */
+const docsRoutePattern: RoutePattern[] = [
+ { type: "literal", name: "docs" },
+ { type: "array-param", paramName: "slug", optional: false },
+];
+
+/**
+ * Mock route pattern for /shop/[category]/[product] — multi dynamic segment.
+ */
+const shopRoutePattern: RoutePattern[] = [
+ { type: "literal", name: "shop" },
+ { type: "string-param", paramName: "category", optional: false },
+ { type: "string-param", paramName: "product", optional: false },
+];
+
/**
* Mock sitemap data for stories.
*/
@@ -43,6 +60,10 @@ export const mockSitemap: SitemapItem = {
urlPath: "/blogs/blog-2",
sourcePath:
'/app/blogs/[blog]/page.val.ts?p="/blogs/blog-2"' as SourcePath,
+ errors: {
+ ownCount: 2,
+ firstMessage: "Required field `title` is missing",
+ },
children: [],
hasError: true,
},
@@ -79,6 +100,59 @@ export const mockSitemap: SitemapItem = {
},
],
},
+ {
+ name: "docs",
+ urlPath: "/docs",
+ canAddChild: true,
+ moduleFilePath: "/app/docs/[...slug]/page.val.ts" as ModuleFilePath,
+ routePattern: docsRoutePattern,
+ existingKeys: ["/guides/intro", "/api/auth"],
+ children: [
+ {
+ name: "guides/intro",
+ urlPath: "/docs/guides/intro",
+ sourcePath:
+ '/app/docs/[...slug]/page.val.ts?p="/docs/guides/intro"' as SourcePath,
+ children: [],
+ },
+ {
+ name: "api/auth",
+ urlPath: "/docs/api/auth",
+ sourcePath:
+ '/app/docs/[...slug]/page.val.ts?p="/docs/api/auth"' as SourcePath,
+ children: [],
+ },
+ ],
+ },
+ {
+ name: "shop",
+ urlPath: "/shop",
+ canAddChild: true,
+ moduleFilePath:
+ "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath,
+ routePattern: shopRoutePattern,
+ existingKeys: ["/electronics", "/clothing"],
+ children: [
+ {
+ name: "electronics",
+ urlPath: "/shop/electronics",
+ canAddChild: true,
+ moduleFilePath:
+ "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath,
+ routePattern: shopRoutePattern,
+ existingKeys: ["/phone"],
+ children: [
+ {
+ name: "phone",
+ urlPath: "/shop/electronics/phone",
+ sourcePath:
+ '/app/shop/[category]/[product]/page.val.ts?p="/shop/electronics/phone"' as SourcePath,
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
],
};
@@ -106,7 +180,10 @@ export const mockExplorer: ExplorerItem = {
fullPath: "/content/settings.val.ts",
isDirectory: false,
children: [],
- hasError: true,
+ errors: {
+ ownCount: 3,
+ firstMessage: "Field `siteUrl` must be a valid URL",
+ },
},
],
},
@@ -213,7 +290,13 @@ export const mockLargeExplorer: ExplorerItem = {
fullPath: `/content/article-${i + 1}.val.ts`,
isDirectory: false,
children: [],
- hasError: i === 5, // One file has an error
+ errors:
+ i === 5
+ ? {
+ ownCount: 1,
+ firstMessage: "Image alt text is required",
+ }
+ : undefined,
})),
},
{
diff --git a/packages/ui/spa/components/NavMenu/stories/NewPageForm.stories.tsx b/packages/ui/spa/components/NavMenu/stories/NewPageForm.stories.tsx
new file mode 100644
index 000000000..a70f768ad
--- /dev/null
+++ b/packages/ui/spa/components/NavMenu/stories/NewPageForm.stories.tsx
@@ -0,0 +1,110 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { ModuleFilePath } from "@valbuild/core";
+import { RoutePattern } from "@valbuild/shared/internal";
+import { NewPageForm, AvailableRoute } from "../NewPageForm";
+
+const meta: Meta = {
+ title: "NavMenu/NewPageForm",
+ component: NewPageForm,
+ parameters: {
+ layout: "padded",
+ },
+ tags: ["autodocs"],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+const blogsPattern: RoutePattern[] = [
+ { type: "literal", name: "blogs" },
+ { type: "string-param", paramName: "blog", optional: false },
+];
+
+const docsPattern: RoutePattern[] = [
+ { type: "literal", name: "docs" },
+ { type: "array-param", paramName: "slug", optional: false },
+];
+
+const shopPattern: RoutePattern[] = [
+ { type: "literal", name: "shop" },
+ { type: "string-param", paramName: "category", optional: false },
+ { type: "string-param", paramName: "product", optional: false },
+];
+
+const blogsRoute: AvailableRoute = {
+ moduleFilePath: "/app/blogs/[blog]/page.val.ts" as ModuleFilePath,
+ routePattern: blogsPattern,
+ patternString: "/blogs/[blog]",
+ existingKeys: ["/blogs/blog-1", "/blogs/blog-2"],
+};
+
+const docsRoute: AvailableRoute = {
+ moduleFilePath: "/app/docs/[...slug]/page.val.ts" as ModuleFilePath,
+ routePattern: docsPattern,
+ patternString: "/docs/[...slug]",
+ existingKeys: ["/docs/guides/intro"],
+};
+
+const shopRoute: AvailableRoute = {
+ moduleFilePath:
+ "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath,
+ routePattern: shopPattern,
+ patternString: "/shop/[category]/[product]",
+ existingKeys: ["/shop/electronics/phone"],
+};
+
+export const SingleDynamicSegment: Story = {
+ render: () => (
+
+ console.log("Create:", { moduleFilePath, urlPath })
+ }
+ onCancel={() => console.log("Cancel")}
+ />
+ ),
+};
+
+export const CatchAllSegment: Story = {
+ render: () => (
+
+ console.log("Create:", { moduleFilePath, urlPath })
+ }
+ onCancel={() => console.log("Cancel")}
+ />
+ ),
+};
+
+export const MultipleSegments: Story = {
+ render: () => (
+
+ console.log("Create:", { moduleFilePath, urlPath })
+ }
+ onCancel={() => console.log("Cancel")}
+ />
+ ),
+};
+
+export const MultipleRoutes: Story = {
+ render: () => (
+
+ console.log("Create:", { moduleFilePath, urlPath })
+ }
+ onCancel={() => console.log("Cancel")}
+ />
+ ),
+ name: "Multiple routes (shows dropdown)",
+};
diff --git a/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx b/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx
index 4da3e3a29..2c56117d3 100644
--- a/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx
+++ b/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
+import { ModuleFilePath, SourcePath } from "@valbuild/core";
+import { RoutePattern } from "@valbuild/shared/internal";
import { SitemapSection } from "../SitemapSection";
import { mockSitemap, mockLargeSitemap } from "../mockData";
import { Accordion } from "../../designSystem/accordion";
+import { SitemapItem } from "../types";
const meta: Meta = {
title: "NavMenu/SitemapSection",
@@ -48,7 +51,7 @@ export const Default: Story = {
}
/>
),
- name: "Default (with Add button)",
+ name: "Default",
};
export const Collapsed: Story = {
@@ -74,6 +77,124 @@ export const WithActivePath: Story = {
),
};
+const catchAllRoutePattern: RoutePattern[] = [
+ { type: "literal", name: "docs" },
+ { type: "array-param", paramName: "slug", optional: false },
+];
+
+const catchAllSitemap: SitemapItem = {
+ name: "/",
+ urlPath: "/",
+ children: [
+ {
+ name: "docs",
+ urlPath: "/docs",
+ canAddChild: true,
+ moduleFilePath: "/app/docs/[...slug]/page.val.ts" as ModuleFilePath,
+ routePattern: catchAllRoutePattern,
+ existingKeys: ["/guides/intro", "/api/auth", "/guides/advanced/forms"],
+ children: [
+ {
+ name: "guides/intro",
+ urlPath: "/docs/guides/intro",
+ sourcePath:
+ '/app/docs/[...slug]/page.val.ts?p="/docs/guides/intro"' as SourcePath,
+ children: [],
+ },
+ {
+ name: "api/auth",
+ urlPath: "/docs/api/auth",
+ sourcePath:
+ '/app/docs/[...slug]/page.val.ts?p="/docs/api/auth"' as SourcePath,
+ children: [],
+ },
+ ],
+ },
+ ],
+};
+
+export const CatchAllRoute: Story = {
+ render: () => (
+ console.log("Navigate to:", path)}
+ onAddPage={(moduleFilePath, urlPath) =>
+ console.log("Add page:", { moduleFilePath, urlPath })
+ }
+ />
+ ),
+ name: "Catch-all route (open New page popover)",
+};
+
+const multiSegmentRoutePattern: RoutePattern[] = [
+ { type: "literal", name: "shop" },
+ { type: "string-param", paramName: "category", optional: false },
+ { type: "string-param", paramName: "product", optional: false },
+];
+
+const multiSegmentSitemap: SitemapItem = {
+ name: "/",
+ urlPath: "/",
+ children: [
+ {
+ name: "shop",
+ urlPath: "/shop",
+ canAddChild: true,
+ moduleFilePath:
+ "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath,
+ routePattern: multiSegmentRoutePattern,
+ existingKeys: ["/electronics"],
+ children: [
+ {
+ name: "electronics",
+ urlPath: "/shop/electronics",
+ canAddChild: true,
+ moduleFilePath:
+ "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath,
+ routePattern: multiSegmentRoutePattern,
+ existingKeys: ["/phone"],
+ children: [
+ {
+ name: "phone",
+ urlPath: "/shop/electronics/phone",
+ sourcePath:
+ '/app/shop/[category]/[product]/page.val.ts?p="/shop/electronics/phone"' as SourcePath,
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+export const MultiSegmentRoute: Story = {
+ render: () => (
+ console.log("Navigate to:", path)}
+ onAddPage={(moduleFilePath, urlPath) =>
+ console.log("Add page:", { moduleFilePath, urlPath })
+ }
+ />
+ ),
+ name: "Multi-segment route (/shop/[category]/[product])",
+};
+
+export const WithValidationErrors: Story = {
+ render: () => (
+ console.log("Navigate to:", path)}
+ onAddPage={(moduleFilePath, urlPath) =>
+ console.log("Add page:", { moduleFilePath, urlPath })
+ }
+ />
+ ),
+ name: "With validation errors (hover badges for tooltip)",
+};
+
export const LargeSitemap: Story = {
render: () => (
;
/**
* Transforms a SitemapNode (from shared/internal) to our SitemapItem type.
+ *
+ * Each row carries `errors.ownCount` and `errors.firstMessage` derived from
+ * validation errors keyed under this row's sourcePath. Descendant totals are
+ * computed at render time by recursing children.
*/
-function transformSitemapNode(node: SitemapNode | PageNode): SitemapItem {
+function transformSitemapNode(
+ node: SitemapNode | PageNode,
+ errorsMap: ErrorsMap,
+): SitemapItem {
const canAddChild = !!node.pattern?.includes("[");
const routePattern =
canAddChild && node.pattern ? parseRoutePattern(node.pattern) : undefined;
@@ -29,27 +40,45 @@ function transformSitemapNode(node: SitemapNode | PageNode): SitemapItem {
? node.children.map((child) => "/" + child.name)
: undefined;
+ const sourcePath = node.sourcePath as SourcePath | undefined;
+ const errors = sourcePath
+ ? collectErrorsForSitemapEntry(errorsMap, sourcePath)
+ : undefined;
+
return {
name: node.name,
urlPath: node.pattern || "/",
- sourcePath: node.sourcePath as SourcePath | undefined,
+ sourcePath,
moduleFilePath: node.moduleFilePath as ModuleFilePath | undefined,
canAddChild,
routePattern,
existingKeys,
- children: node.children.map(transformSitemapNode),
+ errors,
+ children: node.children.map((child) =>
+ transformSitemapNode(child, errorsMap),
+ ),
};
}
/**
* Transforms a PathNode to our ExplorerItem type.
+ *
+ * Files attribute every error whose sourcePath starts with the file's
+ * fullPath. Directories don't get own errors — descendants are aggregated at
+ * render time.
*/
-function transformPathNode(node: PathNode): ExplorerItem {
+function transformPathNode(node: PathNode, errorsMap: ErrorsMap): ExplorerItem {
+ const isDirectory = !!node.isDirectory;
+ const errors =
+ !isDirectory && node.fullPath
+ ? collectErrorsForModuleFilePath(errorsMap, node.fullPath)
+ : undefined;
return {
name: node.name,
fullPath: node.fullPath,
- isDirectory: !!node.isDirectory,
- children: node.children.map(transformPathNode),
+ isDirectory,
+ errors,
+ children: node.children.map((child) => transformPathNode(child, errorsMap)),
};
}
@@ -66,12 +95,14 @@ export function useNavMenuData(): Remote {
const shallowModules = useShallowModulesAtPaths(sitemapPaths, "record");
const srcFolder = useNextAppRouterSrcFolder();
+ const validationErrors = useAllValidationErrors();
return useMemo((): Remote => {
if (trees.status !== "success") {
return trees;
}
+ const errorsMap: ErrorsMap = validationErrors ?? {};
const data: NavMenuData = {};
// Transform sitemap if available
@@ -96,7 +127,7 @@ export function useNavMenuData(): Remote {
}
}
const sitemapTree = getNextAppRouterSitemapTree(srcFolder.data, paths);
- data.sitemap = transformSitemapNode(sitemapTree);
+ data.sitemap = transformSitemapNode(sitemapTree, errorsMap);
} else if (
srcFolder.status === "loading" ||
shallowModules.status === "loading"
@@ -107,7 +138,7 @@ export function useNavMenuData(): Remote {
// Transform explorer tree if available
if (trees.data.root && trees.data.root.children.length > 0) {
- data.explorer = transformPathNode(trees.data.root);
+ data.explorer = transformPathNode(trees.data.root, errorsMap);
}
// Add external module if available
@@ -122,5 +153,65 @@ export function useNavMenuData(): Remote {
status: "success",
data,
};
- }, [trees, sitemapPaths, srcFolder, shallowModules]);
+ }, [trees, sitemapPaths, srcFolder, shallowModules, validationErrors]);
+}
+
+/**
+ * Collect the errors that resolve to a single sitemap entry.
+ *
+ * A sitemap entry has a sourcePath like
+ * `/app/blogs/[blog]/page.val.ts?p="/blogs/blog-1"`
+ * The errors map is keyed by the *path within the module*, e.g.
+ * `/app/blogs/[blog]/page.val.ts?p="/blogs/blog-1"."title"`
+ *
+ * An error belongs to a row if its key is the row's source path, or extends
+ * into it via a `.` (sub-property of the record entry). This deliberately
+ * avoids matching sibling record entries that share a prefix.
+ */
+function collectErrorsForSitemapEntry(
+ errorsMap: ErrorsMap,
+ sourcePath: SourcePath,
+): NavItemErrors | undefined {
+ let ownCount = 0;
+ let firstMessage: string | undefined;
+ const exactPrefix = `${sourcePath}.`;
+ for (const keyString in errorsMap) {
+ const key = keyString as SourcePath;
+ if (key !== sourcePath && !keyString.startsWith(exactPrefix)) continue;
+ const list = errorsMap[key];
+ if (!list || list.length === 0) continue;
+ ownCount += list.length;
+ if (!firstMessage) firstMessage = list[0]?.message;
+ }
+ return ownCount > 0 ? { ownCount, firstMessage } : undefined;
+}
+
+/**
+ * Collect the errors attributable to a single explorer file.
+ *
+ * Files have a fullPath like `/content/authors.val.ts`. Errors are keyed by
+ * SourcePath which begins with the module file path, followed by `?p="..."`
+ * or `?` at the boundary. A startsWith check on `fullPath` is safe because
+ * sibling files have distinct names.
+ */
+function collectErrorsForModuleFilePath(
+ errorsMap: ErrorsMap,
+ fullPath: string,
+): NavItemErrors | undefined {
+ let ownCount = 0;
+ let firstMessage: string | undefined;
+ for (const keyString in errorsMap) {
+ if (!keyString.startsWith(fullPath)) continue;
+ const next = keyString.charAt(fullPath.length);
+ // Only treat as belonging to this file if the source path either ends
+ // exactly at this file or continues with `?` (module path query
+ // separator). Bare prefixes like `/content/authors.val.ts` matching a
+ // hypothetical sibling `/content/authors.val.ts.backup` are excluded.
+ if (next !== "" && next !== "?") continue;
+ const list = errorsMap[keyString as SourcePath];
+ if (!list || list.length === 0) continue;
+ ownCount += list.length;
+ if (!firstMessage) firstMessage = list[0]?.message;
+ }
+ return ownCount > 0 ? { ownCount, firstMessage } : undefined;
}