From f6a0673082bdb954a737e5051a2b3de81636bd5b Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:14:17 -0700 Subject: [PATCH] Convert from JSX to TSX --- app/api/web-vitals/{route.js => route.ts} | 58 ++++++-- app/blog/[slug]/{page.js => page.tsx} | 21 ++- app/blog/{page.js => page.tsx} | 3 +- .../{AskOrganizer.js => AskOrganizer.tsx} | 47 +++++- app/components/{Avatar.js => Avatar.tsx} | 13 +- ...AvatarContainer.js => AvatarContainer.tsx} | 12 +- app/components/{Button.js => Button.tsx} | 24 ++- app/components/{Checkbox.js => Checkbox.tsx} | 28 +++- ...itionalHeader.js => ConditionalHeader.tsx} | 0 .../{ContentBanner.js => ContentBanner.tsx} | 11 +- ...ntentContainer.js => ContentContainer.tsx} | 13 +- .../{ContentLockup.js => ContentLockup.tsx} | 32 +++- ...mplate.js => ContentThumbnailTemplate.tsx} | 25 ++-- .../{ContextMenu.js => ContextMenu.tsx} | 9 +- app/components/ContextMenuDivider.js | 21 --- app/components/ContextMenuDivider.tsx | 27 ++++ ...ContextMenuItem.js => ContextMenuItem.tsx} | 28 +++- ...tMenuSection.js => ContextMenuSection.tsx} | 10 +- .../{ErrorBoundary.js => ErrorBoundary.tsx} | 19 ++- app/components/FeatureGrid.js | 93 ------------ app/components/FeatureGrid.tsx | 100 +++++++++++++ app/components/{Footer.js => Footer.tsx} | 45 ++---- app/components/{Header.js => Header.tsx} | 46 ++++-- .../{HeaderTab.js => HeaderTab.tsx} | 10 +- .../{HeroBanner.js => HeroBanner.tsx} | 12 +- .../{HeroDecor.js => HeroDecor.tsx} | 6 +- .../{HomeHeader.js => HomeHeader.tsx} | 54 +++++-- ...agePlaceholder.js => ImagePlaceholder.tsx} | 14 +- app/components/{Input.js => Input.tsx} | 52 +++++-- app/components/{Logo.js => Logo.tsx} | 29 +++- app/components/{LogoWall.js => LogoWall.tsx} | 15 +- app/components/{MenuBar.js => MenuBar.tsx} | 12 +- .../{MenuBarItem.js => MenuBarItem.tsx} | 42 ++++-- app/components/{MiniCard.js => MiniCard.tsx} | 17 ++- .../{NavigationItem.js => NavigationItem.tsx} | 20 ++- app/components/NumberedCard.js | 26 ---- app/components/NumberedCard.tsx | 35 +++++ .../{NumberedCards.js => NumberedCards.tsx} | 16 +- .../{QuoteBlock.js => QuoteBlock.tsx} | 45 +++++- .../{QuoteDecor.js => QuoteDecor.tsx} | 6 +- .../{RadioButton.js => RadioButton.tsx} | 22 ++- .../{RadioGroup.js => RadioGroup.tsx} | 26 +++- ...RelatedArticles.js => RelatedArticles.tsx} | 60 ++++---- app/components/{RuleCard.js => RuleCard.tsx} | 28 +++- .../{RuleStack.js => RuleStack.tsx} | 21 ++- .../{SectionHeader.js => SectionHeader.tsx} | 11 +- .../{SectionNumber.js => SectionNumber.tsx} | 8 +- app/components/{Select.js => Select.tsx} | 99 +++++++++---- .../{SelectDropdown.js => SelectDropdown.tsx} | 9 +- .../{SelectOption.js => SelectOption.tsx} | 27 +++- .../{Separator.js => Separator.tsx} | 0 app/components/{Switch.js => Switch.tsx} | 35 +++-- app/components/{TextArea.js => TextArea.tsx} | 53 +++++-- app/components/{Toggle.js => Toggle.tsx} | 51 +++++-- .../{ToggleGroup.js => ToggleGroup.tsx} | 41 ++++-- ...alsDashboard.js => WebVitalsDashboard.tsx} | 42 +++++- app/{layout.js => layout.tsx} | 6 +- app/learn/{page.js => page.tsx} | 0 app/monitor/{page.js => page.tsx} | 0 app/{not-found.js => not-found.tsx} | 0 app/{page.js => page.tsx} | 0 lib/{assetUtils.js => assetUtils.ts} | 8 +- lib/{cache.js => cache.ts} | 117 ++++++++------- lib/{content.js => content.ts} | 138 +++++++++++------- lib/{mdx.js => mdx.ts} | 119 ++++++++++----- lib/types.ts | 37 +++++ lib/{validation.js => validation.ts} | 100 ++++++++++--- tsconfig.json | 8 +- 68 files changed, 1527 insertions(+), 635 deletions(-) rename app/api/web-vitals/{route.js => route.ts} (68%) rename app/blog/[slug]/{page.js => page.tsx} (94%) rename app/blog/{page.js => page.tsx} (96%) rename app/components/{AskOrganizer.js => AskOrganizer.tsx} (76%) rename app/components/{Avatar.js => Avatar.tsx} (69%) rename app/components/{AvatarContainer.js => AvatarContainer.tsx} (65%) rename app/components/{Button.js => Button.tsx} (91%) rename app/components/{Checkbox.js => Checkbox.tsx} (90%) rename app/components/{ConditionalHeader.js => ConditionalHeader.tsx} (100%) rename app/components/{ContentBanner.js => ContentBanner.tsx} (90%) rename app/components/{ContentContainer.js => ContentContainer.tsx} (95%) rename app/components/{ContentLockup.js => ContentLockup.tsx} (93%) rename app/components/{ContentThumbnailTemplate.js => ContentThumbnailTemplate.tsx} (87%) rename app/components/{ContextMenu.js => ContextMenu.tsx} (77%) delete mode 100644 app/components/ContextMenuDivider.js create mode 100644 app/components/ContextMenuDivider.tsx rename app/components/{ContextMenuItem.js => ContextMenuItem.tsx} (82%) rename app/components/{ContextMenuSection.js => ContextMenuSection.tsx} (73%) rename app/components/{ErrorBoundary.js => ErrorBoundary.tsx} (76%) delete mode 100644 app/components/FeatureGrid.js create mode 100644 app/components/FeatureGrid.tsx rename app/components/{Footer.js => Footer.tsx} (68%) rename app/components/{Header.js => Header.tsx} (87%) rename app/components/{HeaderTab.js => HeaderTab.tsx} (90%) rename app/components/{HeroBanner.js => HeroBanner.tsx} (93%) rename app/components/{HeroDecor.js => HeroDecor.tsx} (97%) rename app/components/{HomeHeader.js => HomeHeader.tsx} (87%) rename app/components/{ImagePlaceholder.js => ImagePlaceholder.tsx} (74%) rename app/components/{Input.js => Input.tsx} (82%) rename app/components/{Logo.js => Logo.tsx} (90%) rename app/components/{LogoWall.js => LogoWall.tsx} (94%) rename app/components/{MenuBar.js => MenuBar.tsx} (77%) rename app/components/{MenuBarItem.js => MenuBarItem.tsx} (90%) rename app/components/{MiniCard.js => MiniCard.tsx} (92%) rename app/components/{NavigationItem.js => NavigationItem.tsx} (82%) delete mode 100644 app/components/NumberedCard.js create mode 100644 app/components/NumberedCard.tsx rename app/components/{NumberedCards.js => NumberedCards.tsx} (90%) rename app/components/{QuoteBlock.js => QuoteBlock.tsx} (92%) rename app/components/{QuoteDecor.js => QuoteDecor.tsx} (93%) rename app/components/{RadioButton.js => RadioButton.tsx} (90%) rename app/components/{RadioGroup.js => RadioGroup.tsx} (73%) rename app/components/{RelatedArticles.js => RelatedArticles.tsx} (79%) rename app/components/{RuleCard.js => RuleCard.tsx} (84%) rename app/components/{RuleStack.js => RuleStack.tsx} (89%) rename app/components/{SectionHeader.js => SectionHeader.tsx} (94%) rename app/components/{SectionNumber.js => SectionNumber.tsx} (85%) rename app/components/{Select.js => Select.tsx} (78%) rename app/components/{SelectDropdown.js => SelectDropdown.tsx} (78%) rename app/components/{SelectOption.js => SelectOption.tsx} (81%) rename app/components/{Separator.js => Separator.tsx} (100%) rename app/components/{Switch.js => Switch.tsx} (81%) rename app/components/{TextArea.js => TextArea.tsx} (82%) rename app/components/{Toggle.js => Toggle.tsx} (82%) rename app/components/{ToggleGroup.js => ToggleGroup.tsx} (75%) rename app/components/{WebVitalsDashboard.js => WebVitalsDashboard.tsx} (90%) rename app/{layout.js => layout.tsx} (93%) rename app/learn/{page.js => page.tsx} (100%) rename app/monitor/{page.js => page.tsx} (100%) rename app/{not-found.js => not-found.tsx} (100%) rename app/{page.js => page.tsx} (100%) rename lib/{assetUtils.js => assetUtils.ts} (89%) rename lib/{cache.js => cache.ts} (58%) rename lib/{content.js => content.ts} (64%) rename lib/{mdx.js => mdx.ts} (74%) create mode 100644 lib/types.ts rename lib/{validation.js => validation.ts} (62%) diff --git a/app/api/web-vitals/route.js b/app/api/web-vitals/route.ts similarity index 68% rename from app/api/web-vitals/route.js rename to app/api/web-vitals/route.ts index 6dd1db4..468ce50 100644 --- a/app/api/web-vitals/route.js +++ b/app/api/web-vitals/route.ts @@ -1,20 +1,52 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals"); +interface WebVitalData { + metric: string; + data: { + value: number; + rating: string; + }; + url: string; + userAgent: string; + timestamp: string; + receivedAt: string; +} + +interface WebVitalMetrics { + [metric: string]: { + count: number; + average: number; + min: number; + max: number; + goodCount: number; + needsImprovementCount: number; + poorCount: number; + lastUpdated: string; + }; +} + // Ensure web-vitals directory exists if (!fs.existsSync(WEB_VITALS_DIR)) { fs.mkdirSync(WEB_VITALS_DIR, { recursive: true }); } -export async function POST(request) { +export async function POST(request: NextRequest) { try { - const { metric, data, url, userAgent, timestamp } = await request.json(); + const body = await request.json(); + const { metric, data, url, userAgent, timestamp } = body as { + metric: string; + data: { value: number; rating: string }; + url: string; + userAgent: string; + timestamp: string; + }; // Store the metric data - const vitalsData = { + const vitalsData: WebVitalData = { metric, data, url, @@ -25,13 +57,15 @@ export async function POST(request) { // Save to file (in production, you would save to a database) const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`); - let existingData = []; + let existingData: WebVitalData[] = []; if (fs.existsSync(filePath)) { try { - existingData = JSON.parse(fs.readFileSync(filePath, "utf8")); + const fileContent = fs.readFileSync(filePath, "utf8"); + existingData = JSON.parse(fileContent) as WebVitalData[]; } catch (error) { - console.warn("Could not parse existing vitals data:", error.message); + const err = error as Error; + console.warn("Could not parse existing vitals data:", err.message); } } @@ -61,7 +95,7 @@ export async function POST(request) { export async function GET() { try { - const metrics = {}; + const metrics: WebVitalMetrics = {}; if (fs.existsSync(WEB_VITALS_DIR)) { const files = fs.readdirSync(WEB_VITALS_DIR); @@ -69,9 +103,11 @@ export async function GET() { files.forEach((file) => { if (file.endsWith(".json")) { const metric = file.replace(".json", ""); - const data = JSON.parse( - fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"), + const fileContent = fs.readFileSync( + path.join(WEB_VITALS_DIR, file), + "utf8", ); + const data = JSON.parse(fileContent) as WebVitalData[]; if (data.length > 0) { const values = data @@ -96,7 +132,7 @@ export async function GET() { (r) => r === "needs-improvement", ).length, poorCount: ratings.filter((r) => r === "poor").length, - lastUpdated: data[data.length - 1]?.receivedAt, + lastUpdated: data[data.length - 1]?.receivedAt || "", }; } } diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.tsx similarity index 94% rename from app/blog/[slug]/page.js rename to app/blog/[slug]/page.tsx index 2533b18..ece8c43 100644 --- a/app/blog/[slug]/page.js +++ b/app/blog/[slug]/page.tsx @@ -1,8 +1,9 @@ import { notFound } from "next/navigation"; -import Link from "next/link"; +import type { Metadata } from "next"; import { getBlogPostBySlug, getAllBlogPosts as getAllPosts, + type BlogPost, } from "../../../lib/content"; import ContentBanner from "../../components/ContentBanner"; import RelatedArticles from "../../components/RelatedArticles"; @@ -17,6 +18,10 @@ const askOrganizerData = { buttonHref: "#contact", }; +interface PageProps { + params: Promise<{ slug: string }>; +} + /** * Generate static params for all blog posts * This enables static generation for all blog posts at build time @@ -36,7 +41,9 @@ export async function generateStaticParams() { /** * Generate metadata for each blog post */ -export async function generateMetadata({ params }) { +export async function generateMetadata({ + params, +}: PageProps): Promise { try { const { slug } = await params; const post = getBlogPostBySlug(slug); @@ -80,7 +87,7 @@ export async function generateMetadata({ params }) { /** * Dynamic blog post page */ -export default async function BlogPostPage({ params }) { +export default async function BlogPostPage({ params }: PageProps) { // Get the blog post data const { slug } = await params; const post = getBlogPostBySlug(slug); @@ -97,7 +104,11 @@ export default async function BlogPostPage({ params }) { const slugOrder = allPosts.map((post) => post.slug); // Simple related articles algorithm based on content similarity - const getRelatedArticles = (currentPost, allPosts, limit = 3) => { + const getRelatedArticles = ( + currentPost: BlogPost, + allPosts: BlogPost[], + limit = 3, + ): BlogPost[] => { const otherPosts = allPosts.filter((p) => p.slug !== currentPost.slug); // Score posts based on content similarity @@ -202,7 +213,7 @@ export default async function BlogPostPage({ params }) { }; // Get article-specific background color from frontmatter - const getBackgroundColor = (post) => { + const getBackgroundColor = (post: BlogPost): string => { if (post.frontmatter?.background?.color) { return post.frontmatter.background.color; } diff --git a/app/blog/page.js b/app/blog/page.tsx similarity index 96% rename from app/blog/page.js rename to app/blog/page.tsx index dead414..56b125d 100644 --- a/app/blog/page.js +++ b/app/blog/page.tsx @@ -1,8 +1,9 @@ import { getAllBlogPosts } from "../../lib/content"; import ContentThumbnailTemplate from "../components/ContentThumbnailTemplate"; import ContentContainer from "../components/ContentContainer"; +import type { Metadata } from "next"; -export const metadata = { +export const metadata: Metadata = { title: "Blog - CommunityRule", description: "Learn about community governance, decision-making, and building successful organizations.", diff --git a/app/components/AskOrganizer.js b/app/components/AskOrganizer.tsx similarity index 76% rename from app/components/AskOrganizer.js rename to app/components/AskOrganizer.tsx index aa99ce3..ec97761 100644 --- a/app/components/AskOrganizer.js +++ b/app/components/AskOrganizer.tsx @@ -4,7 +4,35 @@ import React, { memo } from "react"; import ContentLockup from "./ContentLockup"; import Button from "./Button"; -const AskOrganizer = memo( +interface AskOrganizerProps { + title?: string; + subtitle?: string; + description?: string; + buttonText?: string; + buttonHref?: string; + className?: string; + variant?: "centered" | "left-aligned" | "compact" | "inverse"; + onContactClick?: (data: { + event: string; + component: string; + variant: string; + buttonText: string; + buttonHref: string; + timestamp: string; + }) => void; +} + +declare global { + interface Window { + gtag?: ( + command: string, + eventName: string, + params?: Record + ) => void; + } +} + +const AskOrganizer = memo( ({ title, subtitle, @@ -12,11 +40,13 @@ const AskOrganizer = memo( buttonText = "Ask an organizer", buttonHref = "#", className = "", - variant = "centered", // centered, left-aligned, compact - onContactClick, // Analytics callback + variant = "centered", + onContactClick, }) => { // Analytics tracking for contact button clicks - const handleContactClick = (event) => { + const handleContactClick = ( + event: React.MouseEvent + ) => { // Track contact button interaction if (onContactClick) { onContactClick({ @@ -40,7 +70,10 @@ const AskOrganizer = memo( }; // Variant-specific styling - const variantStyles = { + const variantStyles: Record< + string, + { container: string; buttonContainer: string } + > = { centered: { container: "text-center", buttonContainer: "flex justify-center", @@ -98,7 +131,7 @@ const AskOrganizer = memo( variant={variant === "inverse" ? "primary" : "default"} className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]" onClick={handleContactClick} - aria-label={`${buttonText} - Contact an organizer for help`} + ariaLabel={`${buttonText} - Contact an organizer for help`} > {buttonText} @@ -106,7 +139,7 @@ const AskOrganizer = memo( ); - }, + } ); AskOrganizer.displayName = "AskOrganizer"; diff --git a/app/components/Avatar.js b/app/components/Avatar.tsx similarity index 69% rename from app/components/Avatar.js rename to app/components/Avatar.tsx index aa447e7..42a6401 100644 --- a/app/components/Avatar.js +++ b/app/components/Avatar.tsx @@ -1,8 +1,15 @@ import React, { memo } from "react"; -const Avatar = memo( +interface AvatarProps extends React.ImgHTMLAttributes { + src: string; + alt: string; + size?: "small" | "medium" | "large" | "xlarge"; + className?: string; +} + +const Avatar = memo( ({ src, alt, size = "small", className = "", ...props }) => { - const sizeStyles = { + const sizeStyles: Record = { small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)]", medium: "w-[18px] h-[18px]", large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]", @@ -12,7 +19,7 @@ const Avatar = memo( const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover ${sizeStyles[size]} ${className}`; return {alt}; - }, + } ); Avatar.displayName = "Avatar"; diff --git a/app/components/AvatarContainer.js b/app/components/AvatarContainer.tsx similarity index 65% rename from app/components/AvatarContainer.js rename to app/components/AvatarContainer.tsx index e77156c..aaedac9 100644 --- a/app/components/AvatarContainer.js +++ b/app/components/AvatarContainer.tsx @@ -1,8 +1,14 @@ import React, { memo } from "react"; -const AvatarContainer = memo( +interface AvatarContainerProps extends React.HTMLAttributes { + children?: React.ReactNode; + size?: "small" | "medium" | "large" | "xlarge"; + className?: string; +} + +const AvatarContainer = memo( ({ children, size = "small", className = "", ...props }) => { - const sizeStyles = { + const sizeStyles: Record = { small: "flex -space-x-[var(--spacing-scale-008)]", medium: "flex -space-x-[9px]", large: "flex -space-x-[var(--spacing-scale-010)]", @@ -16,7 +22,7 @@ const AvatarContainer = memo( {children} ); - }, + } ); AvatarContainer.displayName = "AvatarContainer"; diff --git a/app/components/Button.js b/app/components/Button.tsx similarity index 91% rename from app/components/Button.js rename to app/components/Button.tsx index 497ee44..1621b7a 100644 --- a/app/components/Button.js +++ b/app/components/Button.tsx @@ -1,6 +1,20 @@ import React, { memo } from "react"; -const Button = memo( +interface ButtonProps extends React.ButtonHTMLAttributes { + children: React.ReactNode; + variant?: "default" | "secondary" | "primary" | "outlined" | "dark" | "inverse"; + size?: "xsmall" | "small" | "medium" | "large" | "xlarge"; + className?: string; + disabled?: boolean; + type?: "button" | "submit" | "reset"; + onClick?: (e: React.MouseEvent) => void; + href?: string; + target?: string; + rel?: string; + ariaLabel?: string; +} + +const Button = memo( ({ children, variant = "default", @@ -15,7 +29,7 @@ const Button = memo( ariaLabel, ...props }) => { - const sizeStyles = { + const sizeStyles: Record = { xsmall: "px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]", small: @@ -27,7 +41,7 @@ const Button = memo( "px-[var(--spacing-scale-020)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-008)]", }; - const fontStyles = { + const fontStyles: Record = { xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]", small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]", medium: "font-inter text-[14px] leading-[16px] font-medium tracking-[0%]", @@ -35,7 +49,7 @@ const Button = memo( xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]", }; - const variantStyles = { + const variantStyles: Record = { default: "bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:outline-[var(--border-color-default-brandprimary)] hover:outline-inset hover:scale-[1.02] hover:shadow-lg focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-1 focus:scale-[1.02] active:bg-[var(--color-surface-inverse-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:outline-[var(--border-color-default-brandprimary)] active:outline-offset-1 active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 disabled:hover:shadow-none disabled:hover:outline-none", secondary: @@ -49,7 +63,7 @@ const Button = memo( "bg-transparent text-[var(--color-content-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:scale-[1.02] hover:bg-transparent hover:outline-none focus:outline-1 focus:outline-inset focus:outline-[var(--border-color-default-tertiary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-tertiary)] focus:blur-[0px] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", }; - const hoverOutlineStyles = { + const hoverOutlineStyles: Record = { xsmall: "hover:outline-1", small: "hover:outline-1", medium: "hover:outline-1", diff --git a/app/components/Checkbox.js b/app/components/Checkbox.tsx similarity index 90% rename from app/components/Checkbox.js rename to app/components/Checkbox.tsx index a395c13..1487bcb 100644 --- a/app/components/Checkbox.js +++ b/app/components/Checkbox.tsx @@ -2,12 +2,30 @@ import React, { memo, useId } from "react"; +interface CheckboxProps { + checked?: boolean; + mode?: "standard" | "inverse"; + state?: "default" | "hover" | "focus"; + disabled?: boolean; + label?: string; + className?: string; + onChange?: (data: { + checked: boolean; + value?: string; + event: React.MouseEvent | React.KeyboardEvent; + }) => void; + id?: string; + name?: string; + value?: string; + ariaLabel?: string; +} + /** * Checkbox * A basic controlled checkbox with visual modes and interaction states. * This is a minimal first pass; visuals will be refined collaboratively. */ -const Checkbox = memo( +const Checkbox = memo( ({ checked = false, mode = "standard", // "standard" | "inverse" @@ -38,7 +56,7 @@ const Checkbox = memo( // Visual container depending on state const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`; - const stateStyles = { + const stateStyles: Record = { default: "", hover: "", focus: "", @@ -73,7 +91,7 @@ const Checkbox = memo( const conditionalFocusClass = "focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]"; - const handleToggle = (e) => { + const handleToggle = (e: React.MouseEvent | React.KeyboardEvent) => { if (disabled) return; onChange?.({ checked: !checked, @@ -87,7 +105,7 @@ const Checkbox = memo( const checkboxId = id || `checkbox-${generatedId}`; const accessibilityProps = { - role: "checkbox", + role: "checkbox" as const, "aria-checked": checked ? "true" : "false", ...(disabled && { "aria-disabled": "true", tabIndex: -1 }), ...(!disabled && { tabIndex: 0 }), @@ -160,7 +178,7 @@ const Checkbox = memo( /> ); - }, + } ); Checkbox.displayName = "Checkbox"; diff --git a/app/components/ConditionalHeader.js b/app/components/ConditionalHeader.tsx similarity index 100% rename from app/components/ConditionalHeader.js rename to app/components/ConditionalHeader.tsx diff --git a/app/components/ContentBanner.js b/app/components/ContentBanner.tsx similarity index 90% rename from app/components/ContentBanner.js rename to app/components/ContentBanner.tsx index f2a3511..54609f4 100644 --- a/app/components/ContentBanner.js +++ b/app/components/ContentBanner.tsx @@ -3,10 +3,15 @@ import React, { memo } from "react"; import { getAssetPath } from "../../lib/assetUtils"; import ContentContainer from "./ContentContainer"; +import type { BlogPost } from "../../lib/content"; -const ContentBanner = memo(({ post }) => { +interface ContentBannerProps { + post: BlogPost; +} + +const ContentBanner = memo(({ post }) => { // Get article-specific horizontal thumbnail (small) and banner (md+) - const getBackgroundImage = (post) => { + const getBackgroundImage = (post: BlogPost): string => { if (post.frontmatter?.thumbnail?.horizontal) { return `/content/blog/${post.frontmatter.thumbnail.horizontal}`; } @@ -14,7 +19,7 @@ const ContentBanner = memo(({ post }) => { return getAssetPath("assets/Content_Banner.svg"); }; - const getBannerImageMd = (post) => { + const getBannerImageMd = (post: BlogPost): string => { // Use banner.horizontal when provided; fallback to horizontal thumbnail if (post.frontmatter?.banner?.horizontal) { return `/content/blog/${post.frontmatter.banner.horizontal}`; diff --git a/app/components/ContentContainer.js b/app/components/ContentContainer.tsx similarity index 95% rename from app/components/ContentContainer.js rename to app/components/ContentContainer.tsx index 6d1835e..4e11b40 100644 --- a/app/components/ContentContainer.js +++ b/app/components/ContentContainer.tsx @@ -2,11 +2,18 @@ import React, { memo } from "react"; import { getAssetPath, ASSETS } from "../../lib/assetUtils"; +import type { BlogPost } from "../../lib/content"; -const ContentContainer = memo( +interface ContentContainerProps { + post: BlogPost; + width?: string; + size?: "xs" | "responsive"; +} + +const ContentContainer = memo( ({ post, width = "200px", size = "responsive" }) => { // Get the corresponding icon based on the same logic as background images - const getIconImage = (slug) => { + const getIconImage = (slug: string): string => { const icons = [ getAssetPath(ASSETS.ICON_1), getAssetPath(ASSETS.ICON_2), @@ -123,7 +130,7 @@ const ContentContainer = memo( ); - }, + } ); ContentContainer.displayName = "ContentContainer"; diff --git a/app/components/ContentLockup.js b/app/components/ContentLockup.tsx similarity index 93% rename from app/components/ContentLockup.js rename to app/components/ContentLockup.tsx index 63c42d2..b98ae66 100644 --- a/app/components/ContentLockup.js +++ b/app/components/ContentLockup.tsx @@ -4,7 +4,31 @@ import React, { memo } from "react"; import Button from "./Button"; import { getAssetPath } from "../../lib/assetUtils"; -const ContentLockup = memo( +interface ContentLockupProps { + title?: string; + subtitle?: string; + description?: string; + ctaText?: string; + ctaHref?: string; + buttonClassName?: string; + variant?: "hero" | "feature" | "learn" | "ask" | "ask-inverse"; + linkText?: string; + linkHref?: string; + alignment?: "center" | "left"; +} + +interface VariantStyle { + container: string; + textContainer: string; + titleGroup: string; + titleContainer: string; + title: string; + subtitle: string; + description?: string; + shape: string; +} + +const ContentLockup = memo( ({ title, subtitle, @@ -15,10 +39,10 @@ const ContentLockup = memo( variant = "hero", linkText, linkHref, - alignment = "center", // center, left + alignment = "center", }) => { // Variant-specific styling - const variantStyles = { + const variantStyles: Record = { hero: { container: "flex flex-col gap-[var(--spacing-scale-006)] sm:gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-020)] relative z-10", @@ -179,7 +203,7 @@ const ContentLockup = memo( )} ); - }, + } ); ContentLockup.displayName = "ContentLockup"; diff --git a/app/components/ContentThumbnailTemplate.js b/app/components/ContentThumbnailTemplate.tsx similarity index 87% rename from app/components/ContentThumbnailTemplate.js rename to app/components/ContentThumbnailTemplate.tsx index b39474a..ef13965 100644 --- a/app/components/ContentThumbnailTemplate.js +++ b/app/components/ContentThumbnailTemplate.tsx @@ -4,19 +4,26 @@ import React, { memo } from "react"; import Link from "next/link"; import ContentContainer from "./ContentContainer"; import { getAssetPath, ASSETS } from "../../lib/assetUtils"; +import type { BlogPost } from "../../lib/content"; /** * ContentThumbnailTemplate component for displaying blog post previews * Simplified version to debug infinite loop */ -const ContentThumbnailTemplate = memo( - ({ - post, - className = "", - variant = "vertical", // Internal prop for testing/development - }) => { +interface ContentThumbnailTemplateProps { + post: BlogPost; + className?: string; + variant?: "vertical" | "horizontal"; + slugOrder?: string[]; +} + +const ContentThumbnailTemplate = memo( + ({ post, className = "", variant = "vertical", slugOrder }) => { // Get article-specific background image from frontmatter - const getBackgroundImage = (post, variant) => { + const getBackgroundImage = ( + post: BlogPost, + variant: "vertical" | "horizontal" + ): string => { // Check if post has thumbnail images defined in frontmatter if (post.frontmatter?.thumbnail) { const imageName = @@ -31,7 +38,7 @@ const ContentThumbnailTemplate = memo( } // Fallback to default images if no thumbnail specified - const fallbackImages = { + const fallbackImages: Record = { vertical: getAssetPath(ASSETS.VERTICAL_1), horizontal: getAssetPath(ASSETS.HORIZONTAL_1), }; @@ -91,7 +98,7 @@ const ContentThumbnailTemplate = memo( ); - }, + } ); ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate"; diff --git a/app/components/ContextMenu.js b/app/components/ContextMenu.tsx similarity index 77% rename from app/components/ContextMenu.js rename to app/components/ContextMenu.tsx index 1498910..03af1c7 100644 --- a/app/components/ContextMenu.js +++ b/app/components/ContextMenu.tsx @@ -2,7 +2,12 @@ import React, { forwardRef, memo } from "react"; -const ContextMenu = forwardRef( +interface ContextMenuProps extends React.HTMLAttributes { + className?: string; + children?: React.ReactNode; +} + +const ContextMenu = forwardRef( ({ className = "", children, ...props }, ref) => { const menuClasses = ` bg-black @@ -28,7 +33,7 @@ const ContextMenu = forwardRef( {children} ); - }, + } ); ContextMenu.displayName = "ContextMenu"; diff --git a/app/components/ContextMenuDivider.js b/app/components/ContextMenuDivider.js deleted file mode 100644 index 9eb2d32..0000000 --- a/app/components/ContextMenuDivider.js +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import React, { forwardRef, memo } from "react"; - -const ContextMenuDivider = forwardRef(({ className = "", ...props }, ref) => { - const dividerClasses = ` - border-t border-[var(--color-border-default-tertiary)] - my-1 - ${className} - ` - .trim() - .replace(/\s+/g, " "); - - return ( -
- ); -}); - -ContextMenuDivider.displayName = "ContextMenuDivider"; - -export default memo(ContextMenuDivider); diff --git a/app/components/ContextMenuDivider.tsx b/app/components/ContextMenuDivider.tsx new file mode 100644 index 0000000..a8d2c1c --- /dev/null +++ b/app/components/ContextMenuDivider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React, { forwardRef, memo } from "react"; + +interface ContextMenuDividerProps extends React.HTMLAttributes { + className?: string; +} + +const ContextMenuDivider = forwardRef( + ({ className = "", ...props }, ref) => { + const dividerClasses = ` + border-t border-[var(--color-border-default-tertiary)] + my-1 + ${className} + ` + .trim() + .replace(/\s+/g, " "); + + return ( +
+ ); + } +); + +ContextMenuDivider.displayName = "ContextMenuDivider"; + +export default memo(ContextMenuDivider); diff --git a/app/components/ContextMenuItem.js b/app/components/ContextMenuItem.tsx similarity index 82% rename from app/components/ContextMenuItem.js rename to app/components/ContextMenuItem.tsx index b84d462..5c5dec7 100644 --- a/app/components/ContextMenuItem.js +++ b/app/components/ContextMenuItem.tsx @@ -2,7 +2,19 @@ import React, { forwardRef, memo, useCallback } from "react"; -const ContextMenuItem = forwardRef( +interface ContextMenuItemProps extends React.HTMLAttributes { + children?: React.ReactNode; + selected?: boolean; + hasSubmenu?: boolean; + disabled?: boolean; + className?: string; + onClick?: ( + e: React.MouseEvent | React.KeyboardEvent + ) => void; + size?: "small" | "medium" | "large"; +} + +const ContextMenuItem = forwardRef( ( { children, @@ -14,9 +26,9 @@ const ContextMenuItem = forwardRef( size = "medium", ...props }, - ref, + ref ) => { - const getTextSize = () => { + const getTextSize = (): string => { switch (size) { case "small": return "text-[10px] leading-[14px]"; @@ -52,16 +64,16 @@ const ContextMenuItem = forwardRef( .replace(/\s+/g, " "); const handleClick = useCallback( - (e) => { + (e: React.MouseEvent) => { if (!disabled && onClick) { onClick(e); } }, - [disabled, onClick], + [disabled, onClick] ); const handleKeyDown = useCallback( - (e) => { + (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!disabled && onClick) { @@ -69,7 +81,7 @@ const ContextMenuItem = forwardRef( } } }, - [disabled, onClick], + [disabled, onClick] ); return ( @@ -119,7 +131,7 @@ const ContextMenuItem = forwardRef( )}
); - }, + } ); ContextMenuItem.displayName = "ContextMenuItem"; diff --git a/app/components/ContextMenuSection.js b/app/components/ContextMenuSection.tsx similarity index 73% rename from app/components/ContextMenuSection.js rename to app/components/ContextMenuSection.tsx index 2592ae3..789f687 100644 --- a/app/components/ContextMenuSection.js +++ b/app/components/ContextMenuSection.tsx @@ -2,7 +2,13 @@ import React, { forwardRef, memo } from "react"; -const ContextMenuSection = forwardRef( +interface ContextMenuSectionProps extends React.HTMLAttributes { + title?: string; + children?: React.ReactNode; + className?: string; +} + +const ContextMenuSection = forwardRef( ({ title, children, className = "", ...props }, ref) => { const sectionClasses = ` ${className} @@ -22,7 +28,7 @@ const ContextMenuSection = forwardRef( {children}
); - }, + } ); ContextMenuSection.displayName = "ContextMenuSection"; diff --git a/app/components/ErrorBoundary.js b/app/components/ErrorBoundary.tsx similarity index 76% rename from app/components/ErrorBoundary.js rename to app/components/ErrorBoundary.tsx index 19e7a8b..915a6a1 100644 --- a/app/components/ErrorBoundary.js +++ b/app/components/ErrorBoundary.tsx @@ -1,19 +1,28 @@ "use client"; -import React, { Component } from "react"; +import React, { Component, type ReactNode } from "react"; -class ErrorBoundary extends Component { - constructor(props) { +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, error: null }; } - static getDerivedStateFromError(error) { + static getDerivedStateFromError(error: Error): ErrorBoundaryState { // Update state so the next render will show the fallback UI return { hasError: true, error }; } - componentDidCatch(error, errorInfo) { + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // Log the error to an error reporting service console.error("ErrorBoundary caught an error:", error, errorInfo); } diff --git a/app/components/FeatureGrid.js b/app/components/FeatureGrid.js deleted file mode 100644 index 9e879e9..0000000 --- a/app/components/FeatureGrid.js +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import React, { memo, useMemo } from "react"; -import ContentLockup from "./ContentLockup"; -import MiniCard from "./MiniCard"; -import Image from "next/image"; - -const FeatureGrid = memo(({ title, subtitle, className = "" }) => { - // Memoize the feature data to prevent unnecessary re-renders - const features = useMemo( - () => [ - { - backgroundColor: "bg-[var(--color-surface-default-brand-royal)]", - labelLine1: "Decision-making", - labelLine2: "support", - panelContent: "/assets/Feature_Support.png", - ariaLabel: "Decision-making support tools", - href: "#decision-making", - }, - { - backgroundColor: "bg-[#D1FFE2]", - labelLine1: "Values alignment", - labelLine2: "exercises", - panelContent: "/assets/Feature_Exercises.png", - ariaLabel: "Values alignment exercises", - href: "#values-alignment", - }, - { - backgroundColor: "bg-[#F4CAFF]", - labelLine1: "Membership", - labelLine2: "guidance", - panelContent: "/assets/Feature_Guidance.png", - ariaLabel: "Membership guidance resources", - href: "#membership-guidance", - }, - { - backgroundColor: "bg-[#CBDDFF]", - labelLine1: "Conflict resolution", - labelLine2: "tools", - panelContent: "/assets/Feature_Tools.png", - ariaLabel: "Conflict resolution tools", - href: "#conflict-resolution", - }, - ], - [], - ); - return ( -
-
-
- {/* Feature Content Lockup */} -
- -
- - {/* MiniCard Grid */} -
- {features.map((feature, index) => ( - - ))} -
-
-
-
- ); -}); - -FeatureGrid.displayName = "FeatureGrid"; - -export default FeatureGrid; diff --git a/app/components/FeatureGrid.tsx b/app/components/FeatureGrid.tsx new file mode 100644 index 0000000..4b39450 --- /dev/null +++ b/app/components/FeatureGrid.tsx @@ -0,0 +1,100 @@ +"use client"; + +import React, { memo, useMemo } from "react"; +import ContentLockup from "./ContentLockup"; +import MiniCard from "./MiniCard"; + +interface FeatureGridProps { + title?: string; + subtitle?: string; + className?: string; +} + +const FeatureGrid = memo( + ({ title, subtitle, className = "" }) => { + // Memoize the feature data to prevent unnecessary re-renders + const features = useMemo( + () => [ + { + backgroundColor: "bg-[var(--color-surface-default-brand-royal)]", + labelLine1: "Decision-making", + labelLine2: "support", + panelContent: "/assets/Feature_Support.png", + ariaLabel: "Decision-making support tools", + href: "#decision-making", + }, + { + backgroundColor: "bg-[#D1FFE2]", + labelLine1: "Values alignment", + labelLine2: "exercises", + panelContent: "/assets/Feature_Exercises.png", + ariaLabel: "Values alignment exercises", + href: "#values-alignment", + }, + { + backgroundColor: "bg-[#F4CAFF]", + labelLine1: "Membership", + labelLine2: "guidance", + panelContent: "/assets/Feature_Guidance.png", + ariaLabel: "Membership guidance resources", + href: "#membership-guidance", + }, + { + backgroundColor: "bg-[#CBDDFF]", + labelLine1: "Conflict resolution", + labelLine2: "tools", + panelContent: "/assets/Feature_Tools.png", + ariaLabel: "Conflict resolution tools", + href: "#conflict-resolution", + }, + ], + [] + ); + return ( +
+
+
+ {/* Feature Content Lockup */} +
+ +
+ + {/* MiniCard Grid */} +
+ {features.map((feature, index) => ( + + ))} +
+
+
+
+ ); + } +); + +FeatureGrid.displayName = "FeatureGrid"; + +export default FeatureGrid; diff --git a/app/components/Footer.js b/app/components/Footer.tsx similarity index 68% rename from app/components/Footer.js rename to app/components/Footer.tsx index b0facad..aeaf386 100644 --- a/app/components/Footer.js +++ b/app/components/Footer.tsx @@ -101,56 +101,29 @@ const Footer = memo(() => { - {/* Navigation Section */} -
+ {/* Links Section */} +
- {/* Bottom section */} -
-
- © All right reserved -
- + {/* Copyright */} +
+ © {new Date().getFullYear()} Media Economies Design Lab. All rights + reserved.
diff --git a/app/components/Header.js b/app/components/Header.tsx similarity index 87% rename from app/components/Header.js rename to app/components/Header.tsx index 7e18ebd..8bb6b15 100644 --- a/app/components/Header.js +++ b/app/components/Header.tsx @@ -24,19 +24,23 @@ export const avatarImages = [ ]; export const logoConfig = [ - { breakpoint: "block sm:hidden", size: "header", showText: false }, - { breakpoint: "hidden sm:block md:hidden", size: "header", showText: true }, + { breakpoint: "block sm:hidden", size: "header" as const, showText: false }, + { + breakpoint: "hidden sm:block md:hidden", + size: "header" as const, + showText: true, + }, { breakpoint: "hidden md:block lg:hidden", - size: "headerMd", + size: "headerMd" as const, showText: true, }, { breakpoint: "hidden lg:block xl:hidden", - size: "headerLg", + size: "headerLg" as const, showText: true, }, - { breakpoint: "hidden xl:block", size: "headerXl", showText: true }, + { breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true }, ]; const Header = memo(() => { @@ -55,7 +59,7 @@ const Header = memo(() => { }, }; - const renderNavigationItems = (size) => { + const renderNavigationItems = (size: string) => { return navigationItems.map((item, index) => ( { )); }; - const renderAvatarGroup = (containerSize, avatarSize) => { + const renderAvatarGroup = ( + containerSize: "small" | "medium" | "large" | "xlarge", + avatarSize: "small" | "medium" | "large" | "xlarge" + ) => { return ( {avatarImages.map((avatar, index) => ( @@ -84,7 +91,7 @@ const Header = memo(() => { ); }; - const renderLoginButton = (size) => { + const renderLoginButton = (size: string) => { return ( Log in @@ -92,7 +99,11 @@ const Header = memo(() => { ); }; - const renderCreateRuleButton = (buttonSize, containerSize, avatarSize) => { + const renderCreateRuleButton = ( + buttonSize: string, + containerSize: "small" | "medium" | "large" | "xlarge", + avatarSize: "small" | "medium" | "large" | "xlarge" + ) => { return ( ); - }), + }) ); ToggleGroup.displayName = "ToggleGroup"; diff --git a/app/components/WebVitalsDashboard.js b/app/components/WebVitalsDashboard.tsx similarity index 90% rename from app/components/WebVitalsDashboard.js rename to app/components/WebVitalsDashboard.tsx index fe20a7e..b1de767 100644 --- a/app/components/WebVitalsDashboard.js +++ b/app/components/WebVitalsDashboard.tsx @@ -2,8 +2,36 @@ import React, { useState, useEffect, memo } from "react"; +interface VitalData { + value: number; + rating: "good" | "needs-improvement" | "poor" | "unknown"; +} + +interface Vitals { + lcp: VitalData; + fid: VitalData; + cls: VitalData; + fcp: VitalData; + ttfb: VitalData; +} + +interface MetricData { + count: number; + average: number; + min: number; + max: number; + goodCount: number; + needsImprovementCount: number; + poorCount: number; + lastUpdated?: string; +} + +interface Metrics { + [key: string]: MetricData; +} + const WebVitalsDashboard = memo(() => { - const [vitals, setVitals] = useState({ + const [vitals, setVitals] = useState({ lcp: { value: 0, rating: "unknown" }, fid: { value: 0, rating: "unknown" }, cls: { value: 0, rating: "unknown" }, @@ -11,7 +39,7 @@ const WebVitalsDashboard = memo(() => { ttfb: { value: 0, rating: "unknown" }, }); - const [metrics, setMetrics] = useState({}); + const [metrics, setMetrics] = useState({}); const [loading, setLoading] = useState(true); useEffect(() => { @@ -19,7 +47,7 @@ const WebVitalsDashboard = memo(() => { const fetchVitals = async () => { try { const response = await fetch("/api/web-vitals"); - const data = await response.json(); + const data = (await response.json()) as { metrics?: Metrics }; setMetrics(data.metrics || {}); } catch (error) { console.error("Error fetching web vitals:", error); @@ -88,12 +116,12 @@ const WebVitalsDashboard = memo(() => { }, })); }); - }, + } ); } }, []); - const getRatingColor = (rating) => { + const getRatingColor = (rating: string): string => { switch (rating) { case "good": return "text-green-600 bg-green-50"; @@ -106,7 +134,7 @@ const WebVitalsDashboard = memo(() => { } }; - const getRatingIcon = (rating) => { + const getRatingIcon = (rating: string): string => { switch (rating) { case "good": return "✅"; @@ -119,7 +147,7 @@ const WebVitalsDashboard = memo(() => { } }; - const formatValue = (metric, value) => { + const formatValue = (metric: string, value: number): string => { if (metric === "cls") { return value.toFixed(3); } diff --git a/app/layout.js b/app/layout.tsx similarity index 93% rename from app/layout.js rename to app/layout.tsx index 89d3c51..e1e0ce8 100644 --- a/app/layout.js +++ b/app/layout.tsx @@ -1,4 +1,6 @@ import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google"; +import type { Metadata } from "next"; +import type { ReactNode } from "react"; import "./globals.css"; import Header from "./components/Header"; import HomeHeader from "./components/HomeHeader"; @@ -32,7 +34,7 @@ const spaceGrotesk = Space_Grotesk({ fallback: ["system-ui", "arial"], }); -export const metadata = { +export const metadata: Metadata = { title: "CommunityRule - Build operating manuals for successful communities", description: "Help your community make important decisions in a way that reflects its unique values.", @@ -77,7 +79,7 @@ export const metadata = { }, }; -export default function RootLayout({ children }) { +export default function RootLayout({ children }: { children: ReactNode }) { return ( diff --git a/app/learn/page.js b/app/learn/page.tsx similarity index 100% rename from app/learn/page.js rename to app/learn/page.tsx diff --git a/app/monitor/page.js b/app/monitor/page.tsx similarity index 100% rename from app/monitor/page.js rename to app/monitor/page.tsx diff --git a/app/not-found.js b/app/not-found.tsx similarity index 100% rename from app/not-found.js rename to app/not-found.tsx diff --git a/app/page.js b/app/page.tsx similarity index 100% rename from app/page.js rename to app/page.tsx diff --git a/lib/assetUtils.js b/lib/assetUtils.ts similarity index 89% rename from lib/assetUtils.js rename to lib/assetUtils.ts index 0ef4211..074c9dd 100644 --- a/lib/assetUtils.js +++ b/lib/assetUtils.ts @@ -6,10 +6,10 @@ /** * Get the correct asset path based on environment - * @param {string} assetPath - The asset path (e.g., "assets/Logo.svg") - * @returns {string} - The correct path for the current environment + * @param assetPath - The asset path (e.g., "assets/Logo.svg") + * @returns The correct path for the current environment */ -export function getAssetPath(assetPath) { +export function getAssetPath(assetPath: string): string { // Check if we're in Storybook environment const isStorybook = typeof window !== "undefined" && @@ -55,4 +55,4 @@ export const ASSETS = { // Content page decorative shapes CONTENT_SHAPE_1: "assets/Content_Shape_1.svg", CONTENT_SHAPE_2: "assets/Content_Shape_2.svg", -}; +} as const; diff --git a/lib/cache.js b/lib/cache.ts similarity index 58% rename from lib/cache.js rename to lib/cache.ts index 0446d5a..3727639 100644 --- a/lib/cache.js +++ b/lib/cache.ts @@ -3,10 +3,10 @@ */ // In-memory cache for blog posts -const blogPostCache = new Map(); -const blogListCache = new Map(); -const tagCache = new Map(); -const authorCache = new Map(); +const blogPostCache = new Map>(); +const blogListCache = new Map>(); +const tagCache = new Map>(); +const authorCache = new Map>(); // Cache configuration const isDevelopment = @@ -17,13 +17,16 @@ const MAX_CACHE_SIZE = 100; // Maximum number of cached items /** * Cache entry with timestamp */ -class CacheEntry { - constructor(data) { +class CacheEntry { + data: T; + timestamp: number; + + constructor(data: T) { this.data = data; this.timestamp = Date.now(); } - isExpired() { + isExpired(): boolean { // In development, always consider cache expired (no caching) if (isDevelopment) return true; return Date.now() - this.timestamp > CACHE_TTL; @@ -32,11 +35,11 @@ class CacheEntry { /** * Get cached blog post data - * @param {string} key - Cache key - * @returns {Object|null} Cached data or null if not found/expired + * @param key - Cache key + * @returns Cached data or null if not found/expired */ -function getCached(key) { - const entry = blogPostCache.get(key); +function getCached(key: string): T | null { + const entry = blogPostCache.get(key) as CacheEntry | undefined; if (!entry || entry.isExpired()) { blogPostCache.delete(key); return null; @@ -46,10 +49,10 @@ function getCached(key) { /** * Set cached blog post data - * @param {string} key - Cache key - * @param {Object} data - Data to cache + * @param key - Cache key + * @param data - Data to cache */ -function setCached(key, data) { +function setCached(key: string, data: T): void { // Implement LRU eviction if cache is full if (blogPostCache.size >= MAX_CACHE_SIZE) { const oldestKey = blogPostCache.keys().next().value; @@ -62,7 +65,7 @@ function setCached(key, data) { /** * Clear expired cache entries */ -function clearExpiredCache() { +function clearExpiredCache(): void { for (const [key, entry] of blogPostCache.entries()) { if (entry.isExpired()) { blogPostCache.delete(key); @@ -73,7 +76,7 @@ function clearExpiredCache() { /** * Clear all caches */ -export function clearAllCaches() { +export function clearAllCaches(): void { blogPostCache.clear(); blogListCache.clear(); tagCache.clear(); @@ -82,50 +85,50 @@ export function clearAllCaches() { /** * Get cached blog post by slug - * @param {string} slug - Blog post slug - * @returns {Object|null} Cached blog post or null + * @param slug - Blog post slug + * @returns Cached blog post or null */ -export function getCachedBlogPost(slug) { - return getCached(`post:${slug}`); +export function getCachedBlogPost(slug: string): T | null { + return getCached(`post:${slug}`); } /** * Cache blog post data - * @param {string} slug - Blog post slug - * @param {Object} postData - Blog post data + * @param slug - Blog post slug + * @param postData - Blog post data */ -export function cacheBlogPost(slug, postData) { +export function cacheBlogPost(slug: string, postData: T): void { setCached(`post:${slug}`, postData); } /** * Get cached blog post list - * @param {string} key - Cache key for list (e.g., 'all', 'recent', 'tag:governance') - * @returns {Array|null} Cached list or null + * @param key - Cache key for list (e.g., 'all', 'recent', 'tag:governance') + * @returns Cached list or null */ -export function getCachedBlogList(key) { +export function getCachedBlogList(key: string): T[] | null { const entry = blogListCache.get(key); if (!entry || entry.isExpired()) { blogListCache.delete(key); return null; } - return entry.data; + return entry.data as T[]; } /** * Cache blog post list - * @param {string} key - Cache key - * @param {Array} listData - List data to cache + * @param key - Cache key + * @param listData - List data to cache */ -export function cacheBlogList(key, listData) { +export function cacheBlogList(key: string, listData: T[]): void { blogListCache.set(key, new CacheEntry(listData)); } /** * Get cached tags - * @returns {Array|null} Cached tags or null + * @returns Cached tags or null */ -export function getCachedTags() { +export function getCachedTags(): string[] | null { const entry = tagCache.get("all"); if (!entry || entry.isExpired()) { tagCache.delete("all"); @@ -136,17 +139,17 @@ export function getCachedTags() { /** * Cache tags - * @param {Array} tags - Tags to cache + * @param tags - Tags to cache */ -export function cacheTags(tags) { +export function cacheTags(tags: string[]): void { tagCache.set("all", new CacheEntry(tags)); } /** * Get cached authors - * @returns {Array|null} Cached authors or null + * @returns Cached authors or null */ -export function getCachedAuthors() { +export function getCachedAuthors(): string[] | null { const entry = authorCache.get("all"); if (!entry || entry.isExpired()) { authorCache.delete("all"); @@ -157,17 +160,17 @@ export function getCachedAuthors() { /** * Cache authors - * @param {Array} authors - Authors to cache + * @param authors - Authors to cache */ -export function cacheAuthors(authors) { +export function cacheAuthors(authors: string[]): void { authorCache.set("all", new CacheEntry(authors)); } /** * Invalidate cache for a specific blog post - * @param {string} slug - Blog post slug + * @param slug - Blog post slug */ -export function invalidateBlogPostCache(slug) { +export function invalidateBlogPostCache(slug: string): void { blogPostCache.delete(`post:${slug}`); // Also invalidate list caches since they might contain this post blogListCache.clear(); @@ -176,15 +179,25 @@ export function invalidateBlogPostCache(slug) { /** * Invalidate all caches */ -export function invalidateAllCaches() { +export function invalidateAllCaches(): void { clearAllCaches(); } +export interface CacheStats { + blogPostCacheSize: number; + blogListCacheSize: number; + tagCacheSize: number; + authorCacheSize: number; + totalCacheSize: number; + maxCacheSize: number; + cacheTTL: number; +} + /** * Get cache statistics - * @returns {Object} Cache statistics + * @returns Cache statistics */ -export function getCacheStats() { +export function getCacheStats(): CacheStats { clearExpiredCache(); return { @@ -193,7 +206,7 @@ export function getCacheStats() { tagCacheSize: tagCache.size, authorCacheSize: authorCache.size, totalCacheSize: - blogPostCache.size + blogListCache.size + tagCache.size + authorCacheSize, + blogPostCache.size + blogListCache.size + tagCache.size + authorCache.size, maxCacheSize: MAX_CACHE_SIZE, cacheTTL: CACHE_TTL, }; @@ -201,10 +214,13 @@ export function getCacheStats() { /** * Warm up cache with frequently accessed data - * @param {Function} getAllPosts - Function to get all blog posts - * @param {Function} getAllTags - Function to get all tags + * @param getAllPosts - Function to get all blog posts + * @param getAllTags - Function to get all tags */ -export async function warmCache(getAllPosts, getAllTags) { +export async function warmCache( + getAllPosts: () => T[], + getAllTags: () => string[], +): Promise { try { // Cache all blog posts const allPosts = getAllPosts(); @@ -220,7 +236,8 @@ export async function warmCache(getAllPosts, getAllTags) { // Cache individual posts (first 10) allPosts.slice(0, 10).forEach((post) => { - cacheBlogPost(post.slug, post); + const postWithSlug = post as { slug: string }; + cacheBlogPost(postWithSlug.slug, post); }); console.log("Cache warmed up successfully"); @@ -231,9 +248,9 @@ export async function warmCache(getAllPosts, getAllTags) { /** * Check if cache is healthy - * @returns {boolean} True if cache is healthy + * @returns True if cache is healthy */ -export function isCacheHealthy() { +export function isCacheHealthy(): boolean { try { clearExpiredCache(); return blogPostCache.size < MAX_CACHE_SIZE; diff --git a/lib/content.js b/lib/content.ts similarity index 64% rename from lib/content.js rename to lib/content.ts index 480968c..44b8ed4 100644 --- a/lib/content.js +++ b/lib/content.ts @@ -1,18 +1,31 @@ import fs from "fs"; import path from "path"; import matter from "gray-matter"; -import { validateBlogPost, sanitizeBlogPost } from "./validation.js"; +import { + validateBlogPost, + sanitizeBlogPost, + type BlogPostFrontmatter, +} from "./validation"; /** * Content processing utilities for blog posts */ +export interface BlogPost { + slug: string; + frontmatter: BlogPostFrontmatter; + content: string; + htmlContent: string; + filePath: string; + lastModified: Date; +} + /** * Generate a URL-friendly slug from a string - * @param {string} text - Text to convert to slug - * @returns {string} URL-friendly slug + * @param text - Text to convert to slug + * @returns URL-friendly slug */ -function generateSlug(text) { +function generateSlug(text: string): string { return text .toLowerCase() .replace(/[^\w\s-]/g, "") // Remove special characters @@ -23,9 +36,9 @@ function generateSlug(text) { /** * Get all blog post files from the content directory - * @returns {Array} Array of file paths + * @returns Array of file paths */ -export function markdownToHtml(markdown) { +export function markdownToHtml(markdown: string): string { if (!markdown) return ""; return ( @@ -54,13 +67,13 @@ export function markdownToHtml(markdown) { ); } -export function getBlogPostFiles() { +export function getBlogPostFiles(): string[] { const contentDirectory = path.join(process.cwd(), "content/blog"); try { const files = fs.readdirSync(contentDirectory); return files.filter( - (file) => file.endsWith(".md") || file.endsWith(".mdx"), + (file) => file.endsWith(".md") || file.endsWith(".mdx") ); } catch (error) { console.error("Error reading blog content directory:", error); @@ -70,10 +83,10 @@ export function getBlogPostFiles() { /** * Parse a single blog post file - * @param {string} filePath - Path to the markdown file - * @returns {Object|null} Parsed blog post data or null if invalid + * @param filePath - Path to the markdown file + * @returns Parsed blog post data or null if invalid */ -export function parseBlogPost(filePath) { +export function parseBlogPost(filePath: string): BlogPost | null { const fullPath = path.join(process.cwd(), "content/blog", filePath); try { @@ -84,7 +97,7 @@ export function parseBlogPost(filePath) { if (!validationResult.isValid) { console.error( `Validation errors for ${filePath}:`, - validationResult.errors, + validationResult.errors ); return null; } @@ -108,51 +121,53 @@ export function parseBlogPost(filePath) { /** * Get all blog posts, sorted by date - * @returns {Array} Array of parsed blog post objects + * @returns Array of parsed blog post objects */ -export function getAllBlogPosts() { +export function getAllBlogPosts(): BlogPost[] { const fileNames = getBlogPostFiles(); const allPosts = fileNames .map((fileName) => parseBlogPost(fileName)) - .filter(Boolean) // Filter out nulls (invalid posts) + .filter((post): post is BlogPost => post !== null) // Filter out nulls (invalid posts) .sort( - (a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date), + (a, b) => + new Date(b.frontmatter.date).getTime() - + new Date(a.frontmatter.date).getTime() ); // Sort by date descending return allPosts; } /** * Get a single blog post by its slug - * @param {string} slug - The slug of the blog post - * @returns {Object|null} The parsed blog post data or null if not found + * @param slug - The slug of the blog post + * @returns The parsed blog post data or null if not found */ -export function getBlogPostBySlug(slug) { +export function getBlogPostBySlug(slug: string): BlogPost | null { const allPosts = getAllBlogPosts(); return allPosts.find((post) => post.slug === slug) || null; } /** * Get related blog posts based on provided slugs or fallback to recent posts. - * @param {string} currentPostSlug - The slug of the current post to exclude. - * @param {string[]} relatedSlugs - Array of slugs for explicitly related posts. - * @param {number} limit - Maximum number of related posts to return. - * @returns {Array} Array of related blog post objects. + * @param currentPostSlug - The slug of the current post to exclude. + * @param relatedSlugs - Array of slugs for explicitly related posts. + * @param limit - Maximum number of related posts to return. + * @returns Array of related blog post objects. */ export function getRelatedBlogPosts( - currentPostSlug, - relatedSlugs = [], - limit = 3, -) { + currentPostSlug: string, + relatedSlugs: string[] = [], + limit: number = 3 +): BlogPost[] { const allPosts = getAllBlogPosts(); const filteredPosts = allPosts.filter( - (post) => post.slug !== currentPostSlug, + (post) => post.slug !== currentPostSlug ); - let related = []; + let related: BlogPost[] = []; if (relatedSlugs && relatedSlugs.length > 0) { related = relatedSlugs .map((slug) => filteredPosts.find((post) => post.slug === slug)) - .filter(Boolean); // Filter out any related slugs that don't exist + .filter((post): post is BlogPost => post !== undefined); // Filter out any related slugs that don't exist } // If not enough related posts, or no related slugs provided, fill with recent posts @@ -170,11 +185,11 @@ export function getRelatedBlogPosts( /** * Get all unique tags from all blog posts. - * @returns {string[]} Array of unique tags. + * @returns Array of unique tags. */ -export function getAllTags() { +export function getAllTags(): string[] { const allPosts = getAllBlogPosts(); - const tags = new Set(); + const tags = new Set(); allPosts.forEach((post) => { if (post.frontmatter.tags) { post.frontmatter.tags.forEach((tag) => tags.add(tag)); @@ -185,23 +200,23 @@ export function getAllTags() { /** * Get blog posts filtered by a specific tag. - * @param {string} tag - The tag to filter by. - * @returns {Object[]} Array of blog post objects matching the tag. + * @param tag - The tag to filter by. + * @returns Array of blog post objects matching the tag. */ -export function getBlogPostsByTag(tag) { +export function getBlogPostsByTag(tag: string): BlogPost[] { const allPosts = getAllBlogPosts(); return allPosts.filter( - (post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag), + (post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag) ); } /** * Search blog posts by text content - * @param {string} query - Search query - * @param {number} limit - Maximum number of results - * @returns {Object[]} Array of matching blog post objects + * @param query - Search query + * @param limit - Maximum number of results + * @returns Array of matching blog post objects */ -export function searchBlogPosts(query, limit = 10) { +export function searchBlogPosts(query: string, limit: number = 10): BlogPost[] { if (!query || query.trim() === "") return []; const searchTerm = query.toLowerCase().trim(); @@ -216,7 +231,7 @@ export function searchBlogPosts(query, limit = 10) { .includes(searchTerm); const contentMatch = post.content.toLowerCase().includes(searchTerm); const tagMatch = post.frontmatter.tags?.some((tag) => - tag.toLowerCase().includes(searchTerm), + tag.toLowerCase().includes(searchTerm) ); return titleMatch || descriptionMatch || contentMatch || tagMatch; @@ -227,31 +242,42 @@ export function searchBlogPosts(query, limit = 10) { /** * Get blog posts by author - * @param {string} author - Author name to filter by - * @returns {Object[]} Array of blog post objects by the author + * @param author - Author name to filter by + * @returns Array of blog post objects by the author */ -export function getBlogPostsByAuthor(author) { +export function getBlogPostsByAuthor(author: string): BlogPost[] { const allPosts = getAllBlogPosts(); return allPosts.filter( - (post) => post.frontmatter.author.toLowerCase() === author.toLowerCase(), + (post) => post.frontmatter.author.toLowerCase() === author.toLowerCase() ); } /** * Get recent blog posts - * @param {number} limit - Maximum number of posts to return - * @returns {Object[]} Array of recent blog post objects + * @param limit - Maximum number of posts to return + * @returns Array of recent blog post objects */ -export function getRecentBlogPosts(limit = 5) { +export function getRecentBlogPosts(limit: number = 5): BlogPost[] { const allPosts = getAllBlogPosts(); return allPosts.slice(0, limit); } +export interface BlogStats { + totalPosts: number; + totalTags: number; + totalAuthors: number; + dateRange: { + earliest: string | null; + latest: string | null; + }; + averagePostsPerMonth: number; +} + /** * Get blog post statistics - * @returns {Object} Statistics about blog posts + * @returns Statistics about blog posts */ -export function getBlogStats() { +export function getBlogStats(): BlogStats { const allPosts = getAllBlogPosts(); const tags = getAllTags(); @@ -272,11 +298,13 @@ export function getBlogStats() { (allPosts.length / Math.max( 1, - (new Date(allPosts[0].frontmatter.date) - - new Date(allPosts[allPosts.length - 1].frontmatter.date)) / - (1000 * 60 * 60 * 24 * 30), + (new Date(allPosts[0].frontmatter.date).getTime() - + new Date( + allPosts[allPosts.length - 1].frontmatter.date + ).getTime()) / + (1000 * 60 * 60 * 24 * 30) )) * - 10, + 10 ) / 10 : 0, }; diff --git a/lib/mdx.js b/lib/mdx.ts similarity index 74% rename from lib/mdx.js rename to lib/mdx.ts index de67c15..b3848e4 100644 --- a/lib/mdx.js +++ b/lib/mdx.ts @@ -2,11 +2,47 @@ * MDX processing utilities for enhanced markdown content */ +export interface Heading { + level: number; + text: string; + id: string; + line: number; +} + +export interface Link { + text: string; + url: string; + index: number; +} + +export interface Image { + alt: string; + src: string; + index: number; +} + +export interface ProcessedMarkdown { + content: string; + htmlContent: string; + headings: Heading[]; + links: Link[]; + images: Image[]; +} + +export interface ProcessedFrontmatter { + publishedDate: Date; + year: number; + month: number; + day: number; + isRecent: boolean; + [key: string]: unknown; +} + /** * Format date consistently across the markdown pipeline * Uses "Month Year" format (e.g., "April 2025") */ -export function formatDate(dateString) { +export function formatDate(dateString: string): string { const date = new Date(dateString); return date.toLocaleDateString("en-US", { year: "numeric", @@ -16,10 +52,10 @@ export function formatDate(dateString) { /** * Process markdown content and extract metadata - * @param {string} markdown - Raw markdown content - * @returns {object} Processed content with metadata + * @param markdown - Raw markdown content + * @returns Processed content with metadata */ -export function processMarkdown(markdown) { +export function processMarkdown(markdown: string): ProcessedMarkdown { if (!markdown) { return { content: "", @@ -53,13 +89,13 @@ export function processMarkdown(markdown) { /** * Extract all headings from markdown content - * @param {string} markdown - Raw markdown content - * @returns {Array} Array of heading objects with level, text, and id + * @param markdown - Raw markdown content + * @returns Array of heading objects with level, text, and id */ -function extractHeadings(markdown) { +function extractHeadings(markdown: string): Heading[] { const headingRegex = /^(#{1,6})\s+(.+)$/gm; - const headings = []; - let match; + const headings: Heading[] = []; + let match: RegExpExecArray | null; while ((match = headingRegex.exec(markdown)) !== null) { const level = match[1].length; @@ -79,13 +115,13 @@ function extractHeadings(markdown) { /** * Extract all links from markdown content - * @param {string} markdown - Raw markdown content - * @returns {Array} Array of link objects + * @param markdown - Raw markdown content + * @returns Array of link objects */ -function extractLinks(markdown) { +function extractLinks(markdown: string): Link[] { const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; - const links = []; - let match; + const links: Link[] = []; + let match: RegExpExecArray | null; while ((match = linkRegex.exec(markdown)) !== null) { links.push({ @@ -100,13 +136,13 @@ function extractLinks(markdown) { /** * Extract all images from markdown content - * @param {string} markdown - Raw markdown content - * @returns {Array} Array of image objects + * @param markdown - Raw markdown content + * @returns Array of image objects */ -function extractImages(markdown) { +function extractImages(markdown: string): Image[] { const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; - const images = []; - let match; + const images: Image[] = []; + let match: RegExpExecArray | null; while ((match = imageRegex.exec(markdown)) !== null) { images.push({ @@ -121,10 +157,10 @@ function extractImages(markdown) { /** * Generate a unique ID for a heading - * @param {string} text - Heading text - * @returns {string} Unique ID + * @param text - Heading text + * @returns Unique ID */ -function generateHeadingId(text) { +function generateHeadingId(text: string): string { return text .toLowerCase() .replace(/[^\w\s-]/g, "") @@ -137,10 +173,10 @@ function generateHeadingId(text) { * Convert markdown to HTML with enhanced formatting * - Preserves extra blank lines between paragraphs as visible gaps * (each extra blank line becomes

 

) - * @param {string} markdown - Raw markdown content - * @returns {string} HTML content + * @param markdown - Raw markdown content + * @returns HTML content */ -function markdownToHtml(markdown) { +function markdownToHtml(markdown: string): string { if (!markdown) return ""; // Normalize line endings @@ -266,10 +302,10 @@ function markdownToHtml(markdown) { /** * Generate a table of contents from headings - * @param {Array} headings - Array of heading objects - * @returns {string} HTML table of contents + * @param headings - Array of heading objects + * @returns HTML table of contents */ -export function generateTableOfContents(headings) { +export function generateTableOfContents(headings: Heading[]): string { if (!headings || headings.length === 0) return ""; let toc = '