Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/[filename]/ServerRulePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import { useIsAdminPage } from "@/components/hooks/useIsAdminPage";
import GitHubMetadata from "@/components/last-updated-by";
import RelatedRulesCard from "@/components/RelatedRulesCard";
import RuleActionButtons from "@/components/RuleActionButtons";
import RuleStatusBadge from "@/components/RuleStatusBadge";
import { SocialVideoEmbed } from "@/components/shared/SocialVideoEmbed";
import { getMarkdownComponentMapping } from "@/components/tina-markdown/markdown-component-mapping";
import { Card } from "@/components/ui/card";

export interface ServerRulePageProps {
rule: any;
brokenReferences?: BrokenReferences | null;
pageGeneratedAt?: number;
}

export type ServerRulePagePropsWithTinaProps = {
Expand All @@ -33,7 +35,7 @@ export default function ServerRulePage({ serverRulePageProps, tinaProps }: Serve
const rule = data?.rule;
const { isAdmin: isAdminPage, isLoading: isAdminLoading } = useIsAdminPage();

const { brokenReferences } = serverRulePageProps;
const { brokenReferences, pageGeneratedAt } = serverRulePageProps;
const allCategories = rule.categories
?.map((c: any) => {
const cat = c?.category;
Expand Down Expand Up @@ -87,7 +89,10 @@ export default function ServerRulePage({ serverRulePageProps, tinaProps }: Serve
</h1>

<div className="flex justify-between my-2 flex-col md:flex-row">
<GitHubMetadata owner="SSWConsulting" repo="SSW.Rules.Content" path={rule?.id} className="mt-2" />
<div className="flex items-center gap-2 mt-2">
<RuleStatusBadge pageGeneratedAt={pageGeneratedAt} ruleSlug={rule?.uri} />
<GitHubMetadata owner="SSWConsulting" repo="SSW.Rules.Content" path={rule?.id} />
</div>
<RuleActionButtons rule={rule} />
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions app/[filename]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react";
import categoryTitleIndex from "@/category-uri-title-map.json";
import { Section } from "@/components/layout/section";
import { extractBodyPreview } from "@/lib/bodyUtils";
import { recordPageGeneration } from "@/lib/revalidation-store";
import { siteUrl } from "@/site-config";
import client from "@/tina/__generated__/client";
import { CategoryWithRulesQueryDocument } from "@/tina/__generated__/types";
Expand Down Expand Up @@ -313,13 +314,17 @@ export default async function Page({
const rule = await getRuleData(filename);

if (rule?.data) {
const pageGeneratedAt = Date.now();
recordPageGeneration(filename, pageGeneratedAt);

return (
<Section>
<TinaRuleWrapper
tinaQueryProps={rule}
serverRulePageProps={{
rule: rule.data.rule,
brokenReferences: rule.brokenReferences,
pageGeneratedAt,
}}
/>
</Section>
Expand Down
76 changes: 76 additions & 0 deletions app/api/github-open-prs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { getGitHubAppToken } from "@/lib/services/github/github.utils";
import { GITHUB_API_BASE_URL } from "@/lib/services/github/github.constants";

const CACHE_TTL = 300; // 5 minutes
const GITHUB_ACTIVE_BRANCH = process.env.NEXT_PUBLIC_TINA_BRANCH || "main";

const OPEN_PRS_QUERY = `
query SearchOpenPRs($query: String!, $first: Int!) {
search(query: $query, type: ISSUE, first: $first) {
issueCount
}
}
`;

type CacheEntry = { expiresAt: number; hasOpenPRs: boolean };
const cache = new Map<string, CacheEntry>();

export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const owner = searchParams.get("owner") || "SSWConsulting";
const repo = searchParams.get("repo") || "SSW.Rules.Content";
const path = searchParams.get("path");

if (!path) {
return NextResponse.json({ hasOpenPRs: false });
}

// Extract the rule folder name from the path (e.g. "content/rule/do-something/rule.mdx" -> "do-something")
const ruleFolder = path.split("/").find((_, i, arr) => arr[i + 1] === "rule.mdx") || path.split("/").slice(-2, -1)[0] || "";

if (!ruleFolder) {
return NextResponse.json({ hasOpenPRs: false });
}

const cacheKey = `${owner}/${repo}/${ruleFolder}`;
const cached = cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return NextResponse.json({ hasOpenPRs: cached.hasOpenPRs });
}

try {
const token = await getGitHubAppToken();

const query = `repo:${owner}/${repo} is:pr is:open ${ruleFolder}`;
const response = await fetch(GITHUB_API_BASE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "Rules.V3",
},
body: JSON.stringify({
query: OPEN_PRS_QUERY,
variables: { query, first: 1 },
}),
next: { revalidate: CACHE_TTL },
});

if (!response.ok) {
console.error("GitHub open PRs API error:", response.status);
return NextResponse.json({ hasOpenPRs: false });
}

const result = await response.json();
const issueCount = result?.data?.search?.issueCount ?? 0;
const hasOpenPRs = issueCount > 0;

cache.set(cacheKey, { hasOpenPRs, expiresAt: Date.now() + CACHE_TTL * 1000 });

return NextResponse.json({ hasOpenPRs });
} catch (err) {
console.error("Error checking open PRs:", err);
return NextResponse.json({ hasOpenPRs: false });
}
}
17 changes: 17 additions & 0 deletions app/api/isr-status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getSlugStatus } from "@/lib/revalidation-store";

export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const slug = searchParams.get("slug");

if (!slug || typeof slug !== "string") {
return NextResponse.json({ error: "slug parameter is required" }, { status: 400 });
}

const status = getSlugStatus(slug);

return NextResponse.json(status, {
headers: { "Cache-Control": "no-store" },
});
}
2 changes: 2 additions & 0 deletions app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { revalidatePath, revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
import { recordRevalidation } from "@/lib/revalidation-store";

enum TINA_CONTENT_CHANGE_TYPE {
Modified = "content.modified",
Expand Down Expand Up @@ -36,6 +37,7 @@ export async function POST(req: Request) {
const slug = changedPath.replace("public/uploads/rules/", "").replace("/rule.mdx", "").replace(/\/+$/, "");
if (slug) {
routesToRevalidate.add(`/${slug}`);
recordRevalidation(slug);
}
// If change type is add then we also need to revalidate the /api/rules route
if (eventType === TINA_CONTENT_CHANGE_TYPE.Added) {
Expand Down
54 changes: 54 additions & 0 deletions components/RuleStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client";

import { useEffect, useState } from "react";
import Tooltip from "@/components/tooltip/tooltip";
import { getRuleStatus, getRuleStatusDescription, type RuleStatus } from "@/lib/ruleStatus";
import { cn } from "@/lib/utils";

interface RuleStatusBadgeProps {
pageGeneratedAt?: number;
ruleSlug?: string;
className?: string;
}

const dotStyles = {
fresh: "bg-green-500",
stale: "bg-orange-500",
rebuilding: "bg-yellow-500",
} as const;

export default function RuleStatusBadge({ pageGeneratedAt, ruleSlug, className }: RuleStatusBadgeProps) {
const [status, setStatus] = useState<RuleStatus>("fresh");
const [loaded, setLoaded] = useState(false);

useEffect(() => {
if (!ruleSlug) return;

const fetchStatus = async () => {
try {
const params = new URLSearchParams({ slug: ruleSlug });
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/isr-status?${params.toString()}`);
if (response.ok) {
const { lastWebhookAt } = await response.json();
setStatus(getRuleStatus(pageGeneratedAt, lastWebhookAt));
}
} catch {
// Silently fail — default to "fresh"
} finally {
setLoaded(true);
}
};

fetchStatus();
}, [ruleSlug, pageGeneratedAt]);

if (!loaded) return null;

const description = getRuleStatusDescription(status);

return (
<Tooltip text={description} showDelay={0} hideDelay={0} opaque={true}>
<span className={cn("inline-block h-2.5 w-2.5 rounded-full shrink-0", dotStyles[status], className)} aria-label={description} role="img" />
</Tooltip>
);
}
37 changes: 37 additions & 0 deletions lib/revalidation-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// In-memory store for tracking ISR revalidation events per rule slug.
// NOTE: This store is not shared across server instances. In a multi-instance
// deployment (e.g. multiple Azure containers), each instance maintains its own
// copy. For production at scale, consider a shared store (Redis, Azure Table Storage).

interface SlugStatus {
lastWebhookAt: number | null;
lastGeneratedAt: number | null;
}

const store = new Map<string, SlugStatus>();

function getOrCreate(slug: string): SlugStatus {
let entry = store.get(slug);
if (!entry) {
entry = { lastWebhookAt: null, lastGeneratedAt: null };
store.set(slug, entry);
}
return entry;
}

/** Record that a TinaCMS webhook triggered revalidation for a rule slug. */
export function recordRevalidation(slug: string): void {
const entry = getOrCreate(slug);
entry.lastWebhookAt = Date.now();
}

/** Record that a rule page was generated (ISR) at the given timestamp. */
export function recordPageGeneration(slug: string, timestamp: number): void {
const entry = getOrCreate(slug);
entry.lastGeneratedAt = timestamp;
}

/** Get the revalidation/generation timestamps for a rule slug. */
export function getSlugStatus(slug: string): SlugStatus {
return store.get(slug) ?? { lastWebhookAt: null, lastGeneratedAt: null };
}
45 changes: 45 additions & 0 deletions lib/ruleStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export type RuleStatus = "fresh" | "stale" | "rebuilding";

/** If a webhook was received but the page hasn't regenerated within this window, consider it "rebuilding". Beyond this, it's "stale". */
const REBUILDING_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes

/**
* Determine the ISR status of a rule page by comparing timestamps.
*
* @param pageGeneratedAt - When this static page was last rendered (baked into HTML at ISR time)
* @param lastWebhookAt - When the most recent TinaCMS webhook fired for this slug (from revalidation-store)
* @param now - Current time in ms (defaults to Date.now(), injectable for testing)
*/
export function getRuleStatus(pageGeneratedAt: number | undefined, lastWebhookAt: number | null, now: number = Date.now()): RuleStatus {
// No webhook recorded → page is as fresh as it can be
if (!lastWebhookAt) return "fresh";

// Page was generated after the webhook → content is up to date
if (pageGeneratedAt && pageGeneratedAt >= lastWebhookAt) return "fresh";

// Webhook fired but page hasn't regenerated yet
const elapsed = now - lastWebhookAt;
return elapsed < REBUILDING_THRESHOLD_MS ? "rebuilding" : "stale";
}

export function getRuleStatusLabel(status: RuleStatus): string {
switch (status) {
case "fresh":
return "Fresh";
case "stale":
return "Stale";
case "rebuilding":
return "Being Rebuilt";
}
}

export function getRuleStatusDescription(status: RuleStatus): string {
switch (status) {
case "fresh":
return "This page is up to date with the latest content";
case "stale":
return "Content has changed but this page has not been regenerated yet";
case "rebuilding":
return "Content has changed and the page is being regenerated";
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"postcss": "^8.5.3",
"postcss-import": "^16.1.0",
"postcss-nesting": "^13.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
},
"dependencies": {
Expand Down
Loading