diff --git a/app/[filename]/ClientFallbackPage.tsx b/app/[filename]/ClientFallbackPage.tsx index 7e5d86188..dab17e89a 100644 --- a/app/[filename]/ClientFallbackPage.tsx +++ b/app/[filename]/ClientFallbackPage.tsx @@ -151,6 +151,13 @@ export default function ClientFallbackPage({ filename, searchParams }: ClientFal const sanitizedBasePath = getSanitizedBasePath(); + const brokenReferences = ruleResult._brokenReferences + ? { + detected: ruleResult._brokenReferences.detected, + paths: ruleResult._brokenReferences.paths, + } + : null; + setData({ type: "rule", rule: ruleResult.data.rule, @@ -161,6 +168,7 @@ export default function ClientFallbackPage({ filename, searchParams }: ClientFal query: ruleResult.query, variables: ruleResult.variables, }, + brokenReferences, }); setLoading(false); return; @@ -243,6 +251,7 @@ export default function ClientFallbackPage({ filename, searchParams }: ClientFal rule: data.rule, ruleCategoriesMapping: data.ruleCategoriesMapping, sanitizedBasePath: data.sanitizedBasePath, + brokenReferences: data.brokenReferences, }} /> diff --git a/app/[filename]/ServerRulePage.tsx b/app/[filename]/ServerRulePage.tsx index f8525353d..601bc1dda 100644 --- a/app/[filename]/ServerRulePage.tsx +++ b/app/[filename]/ServerRulePage.tsx @@ -3,6 +3,7 @@ import { tinaField } from "tinacms/dist/react"; import { TinaMarkdown } from "tinacms/dist/rich-text"; import AuthorsCard from "@/components/AuthorsCard"; import Breadcrumbs from "@/components/Breadcrumbs"; +import BrokenReferenceBanner from "@/components/BrokenReferenceBanner"; import CategoriesCard from "@/components/CategoriesCard"; import Discussion from "@/components/Discussion"; import HelpCard from "@/components/HelpCard"; @@ -12,11 +13,13 @@ import RuleActionButtons from "@/components/RuleActionButtons"; import { YouTubeShorts } from "@/components/shared/Youtube"; import { getMarkdownComponentMapping } from "@/components/tina-markdown/markdown-component-mapping"; import { Card } from "@/components/ui/card"; +import type { BrokenReferences } from "@/app/[filename]/page"; export interface ServerRulePageProps { rule: any; ruleCategoriesMapping: { title: string; uri: string }[]; sanitizedBasePath: string; + brokenReferences?: BrokenReferences | null; } export type ServerRulePagePropsWithTinaProps = { @@ -28,7 +31,7 @@ export default function ServerRulePage({ serverRulePageProps, tinaProps }: Serve const { data } = tinaProps; const rule = data?.rule; - const { ruleCategoriesMapping, sanitizedBasePath } = serverRulePageProps; + const { ruleCategoriesMapping, sanitizedBasePath, brokenReferences } = serverRulePageProps; const primaryCategory = ruleCategoriesMapping?.[0]; const breadcrumbCategories = primaryCategory ? [{ title: primaryCategory.title, link: `/${primaryCategory.uri}` }] : undefined; @@ -37,6 +40,10 @@ export default function ServerRulePage({ serverRulePageProps, tinaProps }: Serve <> + {brokenReferences?.detected && ( + + )} +
{rule?.isArchived && rule?.archivedreason && ( diff --git a/app/[filename]/page.tsx b/app/[filename]/page.tsx index 2d115c448..243739ae9 100644 --- a/app/[filename]/page.tsx +++ b/app/[filename]/page.tsx @@ -70,17 +70,56 @@ const getCategoryData = async (filename: string) => { } }; +export interface BrokenReferences { + detected: boolean; + paths: string[]; +} + const getRuleData = async (filename: string) => { try { - const tinaProps = await client.queries.ruleData({ + const basicProps = await client.queries.ruleDataBasic({ relativePath: filename + "/rule.mdx", }); - return { - data: tinaProps.data, - query: tinaProps.query, - variables: tinaProps.variables, - }; + try { + const fullProps = await client.queries.ruleData({ + relativePath: filename + "/rule.mdx", + }); + + return { + data: fullProps.data, + query: fullProps.query, + variables: fullProps.variables, + brokenReferences: null as BrokenReferences | null, + }; + } catch (relatedError) { + const errorMessage = relatedError instanceof Error ? relatedError.message : String(relatedError); + + // Extract all broken paths from error message (there may be multiple) + const brokenPathMatches = errorMessage.matchAll(/Unable to find record ([^\n]+)/g); + const brokenPaths = Array.from(brokenPathMatches, (match) => match[1].trim()); + + // Fallback if no paths found + if (brokenPaths.length === 0) { + brokenPaths.push("unknown path"); + } + + return { + data: { + ...basicProps.data, + rule: { + ...basicProps.data.rule, + related: [], // Clear broken related rules + }, + }, + query: basicProps.query, + variables: basicProps.variables, + brokenReferences: { + detected: true, + paths: brokenPaths, + } as BrokenReferences, + }; + } } catch (error) { console.error(`[getRuleData] failed for filename="${filename}":`, error); return null; @@ -272,6 +311,7 @@ export default async function Page({ rule: rule.data.rule, ruleCategoriesMapping: ruleCategoriesMapping, sanitizedBasePath: sanitizedBasePath, + brokenReferences: rule.brokenReferences, }} /> diff --git a/app/api/tina/rule/route.ts b/app/api/tina/rule/route.ts index 5aba355bd..cb347e5e2 100644 --- a/app/api/tina/rule/route.ts +++ b/app/api/tina/rule/route.ts @@ -10,16 +10,58 @@ export async function GET(request: NextRequest) { if (!relativePath) { return NextResponse.json({ error: "relativePath is required" }, { status: 400 }); } + const fetchOptions = await getFetchOptions(); - // Fetch rule using the Tina client with branch support - let result; - if (fetchOptions) { - result = await client.queries.ruleData({ relativePath }, fetchOptions); - } else { - result = await client.queries.ruleData({ relativePath }); - } - return NextResponse.json(result, { status: 200 }); + // First, try the basic query (without related field resolution) + // This ensures we always get the rule data even if references are broken + const basicResult = fetchOptions + ? await client.queries.ruleDataBasic({ relativePath }, fetchOptions) + : await client.queries.ruleDataBasic({ relativePath }); + + // Now try the full query to get related rules + try { + const fullResult = fetchOptions + ? await client.queries.ruleData({ relativePath }, fetchOptions) + : await client.queries.ruleData({ relativePath }); + + // Full query succeeded - no broken references + return NextResponse.json(fullResult, { status: 200 }); + } catch (relatedError) { + // Full query failed but basic succeeded - broken references detected + const errorMessage = relatedError instanceof Error ? relatedError.message : String(relatedError); + + // Extract all broken paths from error message (there may be multiple) + const brokenPathMatches = errorMessage.matchAll(/Unable to find record ([^\n]+)/g); + const brokenPaths = Array.from(brokenPathMatches, (match) => match[1].trim()); + + // Fallback if no paths found + if (brokenPaths.length === 0) { + brokenPaths.push("unknown path"); + } + + console.warn(`Broken related rule references detected: ${brokenPaths.join(", ")}`); + + // Return basic result with metadata about the broken references + return NextResponse.json( + { + ...basicResult, + data: { + ...basicResult.data, + rule: { + ...basicResult.data.rule, + related: [], // Clear broken related rules + }, + }, + _brokenReferences: { + detected: true, + paths: brokenPaths, + message: `This rule has broken related rule references. Please edit the rule to fix this.`, + }, + }, + { status: 200 } + ); + } } catch (error) { console.error("Error fetching rule from Tina:", error); return NextResponse.json({ error: "Failed to fetch rule", details: error instanceof Error ? error.message : String(error) }, { status: 500 }); diff --git a/components/BrokenReferenceBanner.tsx b/components/BrokenReferenceBanner.tsx new file mode 100644 index 000000000..4d5946927 --- /dev/null +++ b/components/BrokenReferenceBanner.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { useIsAdminPage } from "@/components/hooks/useIsAdminPage"; + +interface BrokenReferenceBannerProps { + brokenPaths: string[]; + ruleUri: string; +} + +export default function BrokenReferenceBanner({ brokenPaths, ruleUri }: BrokenReferenceBannerProps) { + const { isAdmin } = useIsAdminPage(); + + const adminUrl = `/admin#/~/rules/${encodeURIComponent(ruleUri)}`; + + return ( +
+
+
+ +
+
+

+ Broken Related Rule Reference{brokenPaths.length > 1 ? "s" : ""} +

+

+ This rule references {brokenPaths.length > 1 ? "rules that no longer exist" : "a rule that no longer exists"}: +

+
    + {brokenPaths.map((path, idx) => ( +
  • + {path} +
  • + ))} +
+ {isAdmin ? ( +

+ Please edit the Related Rules field to remove or update the broken reference. +

+ ) : ( +

+ + Open in admin panel + {" "} + to fix this issue. +

+ )} +
+
+
+ ); +} diff --git a/tina/queries/queries.gql b/tina/queries/queries.gql index 8e9b7bf3f..cb8df244a 100644 --- a/tina/queries/queries.gql +++ b/tina/queries/queries.gql @@ -290,3 +290,52 @@ query ruleData($relativePath: String!) { } } } + +# Fallback rule query that doesn't resolve related rule references +# Used when ruleData fails due to broken/missing related rule paths +query ruleDataBasic($relativePath: String!) { + rule(relativePath: $relativePath) { + ... on Document { + _sys { + filename + basename + hasReferences + breadcrumbs + path + relativePath + extension + } + id + } + ... on Rule { + title + uri + body + authors { + title + url + } + categories { + category { + ... on CategoryCategory { + uri + title + } + } + } + # Note: related field omitted - this query is used when related rules are broken + redirects + guid + seoDescription + created + createdBy + createdByEmail + lastUpdated + lastUpdatedBy + lastUpdatedByEmail + isArchived + archivedreason + sidebarVideo + } + } +}