[dyad] Add headline hierarchy validation - wrote 1 file(s)

This commit is contained in:
[dyad]
2026-01-21 08:52:08 +01:00
parent 589a5f7827
commit 1f204a523b

View File

@@ -4,7 +4,12 @@ import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { HeadlineNode } from "@/app/actions"; import type { HeadlineNode } from "@/app/actions";
import { KeywordHighlighter } from "./keyword-highlighter"; import { KeywordHighlighter } from "./keyword-highlighter";
import { ChevronDown, ChevronsUp, ChevronsDown } from "lucide-react"; import {
ChevronDown,
ChevronsUp,
ChevronsDown,
AlertTriangle,
} from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -36,6 +41,13 @@ const getCollapsiblePaths = (
return paths; return paths;
}; };
const HierarchyWarning = ({ message }: { message: string }) => (
<div className="flex items-center gap-3 p-4 text-sm text-destructive-foreground bg-destructive/80 border-b">
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
<p className="font-medium">{message}</p>
</div>
);
const HeadlineNodeDisplay = ({ const HeadlineNodeDisplay = ({
node, node,
level, level,
@@ -43,49 +55,79 @@ const HeadlineNodeDisplay = ({
keyword, keyword,
collapsedStates, collapsedStates,
onToggle, onToggle,
parentLevel,
isDuplicateH1,
}: { }: {
node: HeadlineNode; node: HeadlineNode;
level: number; level: number;
path: string; path:string;
keyword?: string | null; keyword?: string | null;
collapsedStates: Record<string, boolean>; collapsedStates: Record<string, boolean>;
onToggle: (path: string) => void; onToggle: (path: string) => void;
parentLevel: number;
isDuplicateH1: boolean;
}) => { }) => {
const isCollapsible = const isCollapsible =
node.level === 2 && node.children && node.children.length > 0; node.level === 2 && node.children && node.children.length > 0;
const isCollapsed = collapsedStates[path]; const isCollapsed = collapsedStates[path];
const levelSkipped = parentLevel > 0 && node.level > parentLevel + 1;
const hasError = isDuplicateH1 || levelSkipped;
let errorTooltip: string | null = null;
if (isDuplicateH1) {
errorTooltip = "Duplicate H1 tag. Only one H1 is recommended per page.";
} else if (levelSkipped) {
errorTooltip = `Incorrect hierarchy. An H${node.level} should not directly follow an H${parentLevel}.`;
}
const content = (
<div
className={cn(
"flex items-center gap-3 py-2.5 pr-4 border-t",
isCollapsible && "cursor-pointer hover:bg-muted/50",
hasError && "bg-destructive/10"
)}
style={{ paddingLeft: `${16 + level * 24}px` }}
onClick={() => isCollapsible && onToggle(path)}
>
<Badge
variant={hasError ? "destructive" : "secondary"}
className="uppercase w-12 flex-shrink-0 justify-center font-mono"
>
{node.tag}
</Badge>
<p className="font-medium flex-grow text-foreground">
<KeywordHighlighter text={node.text} keyword={keyword} />
</p>
<p className="text-sm text-muted-foreground flex-shrink-0 w-10 text-right">
{node.length}
</p>
{isCollapsible && (
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
!isCollapsed && "rotate-180"
)}
/>
)}
</div>
);
return ( return (
<> <>
<div {errorTooltip ? (
className={cn( <TooltipProvider>
"flex items-center gap-3 py-2.5 pr-4 border-t", <Tooltip>
isCollapsible && "cursor-pointer hover:bg-muted/50" <TooltipTrigger asChild>{content}</TooltipTrigger>
)} <TooltipContent>
style={{ paddingLeft: `${16 + level * 24}px` }} <p>{errorTooltip}</p>
onClick={() => isCollapsible && onToggle(path)} </TooltipContent>
> </Tooltip>
<Badge </TooltipProvider>
variant="secondary" ) : (
className="uppercase w-12 flex-shrink-0 justify-center font-mono" content
> )}
{node.tag}
</Badge>
<p className="font-medium flex-grow text-foreground">
<KeywordHighlighter text={node.text} keyword={keyword} />
</p>
<p className="text-sm text-muted-foreground flex-shrink-0 w-10 text-right">
{node.length}
</p>
{isCollapsible && (
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
!isCollapsed && "rotate-180"
)}
/>
)}
</div>
{node.children && {node.children &&
(!isCollapsible || !isCollapsed) && (!isCollapsible || !isCollapsed) &&
node.children.map((child, index) => ( node.children.map((child, index) => (
@@ -97,6 +139,8 @@ const HeadlineNodeDisplay = ({
keyword={keyword} keyword={keyword}
collapsedStates={collapsedStates} collapsedStates={collapsedStates}
onToggle={onToggle} onToggle={onToggle}
parentLevel={node.level}
isDuplicateH1={false}
/> />
))} ))}
</> </>
@@ -139,9 +183,17 @@ export function HeadlineTree({ headlines, keyword }: HeadlineTreeProps) {
}; };
const hasCollapsibleNodes = Object.keys(collapsedStates).length > 0; const hasCollapsibleNodes = Object.keys(collapsedStates).length > 0;
const h1Count = headlines.filter((h) => h.tag === "h1").length;
const firstH1Index = headlines.findIndex((h) => h.tag === "h1");
return ( return (
<div className="w-full rounded-lg overflow-hidden"> <div className="w-full rounded-lg overflow-hidden">
{h1Count === 0 && (
<HierarchyWarning message="No H1 tag found. Every page should have exactly one H1." />
)}
{h1Count > 1 && (
<HierarchyWarning message="Multiple H1 tags found. Only one H1 is recommended per page." />
)}
{hasCollapsibleNodes && ( {hasCollapsibleNodes && (
<div className="flex items-center justify-between p-4 border-b bg-muted/50"> <div className="flex items-center justify-between p-4 border-b bg-muted/50">
<h4 className="font-semibold text-sm text-muted-foreground"> <h4 className="font-semibold text-sm text-muted-foreground">
@@ -197,6 +249,8 @@ export function HeadlineTree({ headlines, keyword }: HeadlineTreeProps) {
keyword={keyword} keyword={keyword}
collapsedStates={collapsedStates} collapsedStates={collapsedStates}
onToggle={handleToggle} onToggle={handleToggle}
parentLevel={0}
isDuplicateH1={headline.tag === "h1" && index !== firstH1Index}
/> />
))} ))}
</div> </div>