[dyad] Add headline hierarchy validation - wrote 1 file(s)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user