diff --git a/apps/web/package.json b/apps/web/package.json
index 7848f7c..987ee07 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -15,7 +15,7 @@
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-radio-group": "^1.2.1",
- "@radix-ui/react-slot": "^1.1.0",
+ "@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
@@ -26,7 +26,9 @@
"clsx": "^2.1.1",
"framer-motion": "^11.15.0",
"geist": "^1.5.1",
+ "gray-matter": "^4.0.3",
"lucide-react": "^0.456.0",
+ "marked": "^17.0.0",
"next": "15.5.3",
"next-auth": "^4.24.11",
"next-themes": "^0.4.3",
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
new file mode 100644
index 0000000..08b4e79
--- /dev/null
+++ b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import "@/styles/newsletter.css";
+
+import { useEffect, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { CalendarIcon, ClockIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useSubscription } from "@/hooks/useSubscription";
+
+interface NewsletterData {
+ title: string;
+ date: string;
+ readTime: string;
+ content: string;
+}
+
+function NewsletterSkeleton() {
+ return (
+
+ );
+}
+
+export default function NewsletterPage() {
+ const params = useParams();
+ const router = useRouter();
+ const slug = params.slug as string;
+ const [newsletter, setNewsletter] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const { isLoading: subscriptionLoading } = useSubscription();
+
+ useEffect(() => {
+ // Fetch for all users (testing mode)
+ if (subscriptionLoading) return;
+
+ fetch(`/api/newsletters/${slug}`)
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.error) {
+ setNewsletter(null);
+ } else {
+ setNewsletter(data);
+ }
+ setLoading(false);
+ })
+ .catch(() => {
+ setNewsletter(null);
+ setLoading(false);
+ });
+ }, [slug, subscriptionLoading]);
+
+ if (subscriptionLoading) {
+ return (
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!newsletter) {
+ return (
+
+
+
Newsletter not found
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {newsletter.title}
+
+
+
+
+
+ {new Date(newsletter.date).toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ })}
+
+
+
+
+ {newsletter.readTime}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
new file mode 100644
index 0000000..536389a
--- /dev/null
+++ b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import "@/styles/newsletter.css";
+
+import { useEffect, useState, useMemo } from "react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useSubscription } from "@/hooks/useSubscription";
+import { Newsletter } from "@/components/newsletters/NewsletterCard";
+import { NewsletterSkeleton } from "@/components/newsletters/NewsletterSkeleton";
+import { PremiumUpgradePrompt } from "@/components/newsletters/PremiumUpgradePrompt";
+import { NewsletterFilters, TimeFilter } from "@/components/newsletters/NewsletterFilters";
+import { NewsletterPagination } from "@/components/newsletters/NewsletterPagination";
+import { NewsletterList } from "@/components/newsletters/NewsletterList";
+import { useNewsletterFilters } from "@/hooks/useNewsletterFilters";
+
+export default function NewslettersPage() {
+ const [newsletters, setNewsletters] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [timeFilter, setTimeFilter] = useState("all");
+ const [currentPage, setCurrentPage] = useState(1);
+ const { isPaidUser, isLoading: subscriptionLoading } = useSubscription();
+
+ const itemsPerPage = 5;
+
+ useEffect(() => {
+ // Fetch newsletters for all users (testing mode)
+ if (subscriptionLoading) return;
+
+ fetch("/api/newsletters")
+ .then((res) => res.json())
+ .then((data) => {
+ setNewsletters(data);
+ setLoading(false);
+ })
+ .catch(() => setLoading(false));
+ }, [subscriptionLoading]);
+
+ const filteredNewsletters = useNewsletterFilters(newsletters, searchQuery, timeFilter);
+
+ const totalPages = Math.ceil(filteredNewsletters.length / itemsPerPage);
+ const paginatedNewsletters = useMemo(() => {
+ const startIndex = (currentPage - 1) * itemsPerPage;
+ return filteredNewsletters.slice(startIndex, startIndex + itemsPerPage);
+ }, [filteredNewsletters, currentPage]);
+
+ // Reset to page 1 when filters change
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [searchQuery, timeFilter]);
+
+ if (subscriptionLoading) {
+ return (
+
+ );
+ }
+
+ if (!isPaidUser) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Newsletters
+
+
+ Stay updated with our latest news and insights
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/api/newsletters/[slug]/route.ts b/apps/web/src/app/api/newsletters/[slug]/route.ts
new file mode 100644
index 0000000..3148322
--- /dev/null
+++ b/apps/web/src/app/api/newsletters/[slug]/route.ts
@@ -0,0 +1,57 @@
+import { NextResponse } from "next/server";
+import fs from "fs";
+import path from "path";
+import matter from "gray-matter";
+import { marked } from "marked";
+
+// Configure marked for rich markdown support
+marked.setOptions({
+ gfm: true, // GitHub Flavored Markdown: tables, task lists, etc.
+ breaks: true, // Line breaks
+});
+
+// Cache individual newsletters
+const newsletterCache = new Map();
+const CACHE_DURATION = 60_000; // 1 minute
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ slug: string }> }
+) {
+ const { slug } = await params;
+ const now = Date.now();
+ const cached = newsletterCache.get(slug);
+
+ if (cached && now - cached.time < CACHE_DURATION) {
+ return NextResponse.json(cached.data);
+ }
+
+ const newslettersDir = path.join(process.cwd(), "src/content/newsletters");
+ const filePath = path.join(newslettersDir, `${slug}.md`);
+
+ try {
+ if (!fs.existsSync(filePath)) {
+ return NextResponse.json({ error: "Newsletter not found" }, { status: 404 });
+ }
+
+ const fileContent = fs.readFileSync(filePath, "utf8");
+ const { data, content } = matter(fileContent);
+
+ // Render markdown (supports headings, links, lists, code blocks, images, tables, etc.)
+ const htmlContent = marked.parse(content);
+
+ const result = {
+ title: data.title || "Untitled",
+ date: data.date || new Date().toISOString(),
+ readTime: data.readTime || "5 min read",
+ content: htmlContent,
+ };
+
+ newsletterCache.set(slug, { data: result, time: now });
+
+ return NextResponse.json(result);
+ } catch (error) {
+ console.error("Error reading newsletter:", error);
+ return NextResponse.json({ error: "Newsletter not found" }, { status: 404 });
+ }
+}
diff --git a/apps/web/src/app/api/newsletters/route.ts b/apps/web/src/app/api/newsletters/route.ts
new file mode 100644
index 0000000..179b842
--- /dev/null
+++ b/apps/web/src/app/api/newsletters/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server";
+import fs from "fs";
+import path from "path";
+import matter from "gray-matter";
+
+// Cache newsletters in memory for faster subsequent loads
+let cachedNewsletters: any[] | null = null;
+let lastCacheTime = 0;
+const CACHE_DURATION = 60000; // 1 minute cache
+
+export async function GET() {
+ const now = Date.now();
+
+ // Return cached data if available and fresh
+ if (cachedNewsletters && now - lastCacheTime < CACHE_DURATION) {
+ return NextResponse.json(cachedNewsletters);
+ }
+
+ const newslettersDir = path.join(process.cwd(), "src/content/newsletters");
+
+ try {
+ if (!fs.existsSync(newslettersDir)) {
+ fs.mkdirSync(newslettersDir, { recursive: true });
+ return NextResponse.json([]);
+ }
+
+ const files = fs.readdirSync(newslettersDir);
+
+ const newsletters = files
+ .filter((file) => file.endsWith(".md"))
+ .map((file) => {
+ const filePath = path.join(newslettersDir, file);
+ const fileContent = fs.readFileSync(filePath, "utf8");
+ const { data } = matter(fileContent);
+
+ return {
+ slug: file.replace(".md", ""),
+ title: data.title || "Untitled",
+ date: data.date || new Date().toISOString(),
+ excerpt: data.excerpt || "",
+ readTime: data.readTime || "5 min read",
+ };
+ })
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+
+ // Update cache
+ cachedNewsletters = newsletters;
+ lastCacheTime = now;
+
+ return NextResponse.json(newsletters);
+ } catch (error) {
+ console.error("Error reading newsletters:", error);
+ return NextResponse.json([]);
+ }
+}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 6ae57e0..7d204d3 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -60,9 +60,7 @@
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
-}
-@layer base {
* {
@apply border-border;
}
@@ -73,4 +71,5 @@
html {
scroll-behavior: smooth;
-}
\ No newline at end of file
+}
+
diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx
index eabb046..a8cf189 100644
--- a/apps/web/src/components/dashboard/Sidebar.tsx
+++ b/apps/web/src/components/dashboard/Sidebar.tsx
@@ -17,12 +17,14 @@ import {
StarIcon,
HeartIcon,
EnvelopeIcon,
+ NewspaperIcon,
} from "@heroicons/react/24/outline";
import { useShowSidebar } from "@/store/useShowSidebar";
import { signOut } from "next-auth/react";
import { Twitter } from "../icons/icons";
import { ProfilePic } from "./ProfilePic";
import { useFilterStore } from "@/store/useFilterStore";
+import { Badge } from "@/components/ui/badge";
const SIDEBAR_ROUTES = [
{
@@ -112,8 +114,6 @@ export default function Sidebar() {
- {/* Find projects entry */}
-
{SIDEBAR_ROUTES.map((route) => {
const activeClass = getSidebarLinkClassName(pathname, route.path);
return (
@@ -126,12 +126,35 @@ export default function Sidebar() {
);
})}
+
}
collapsed={isCollapsed}
/>
+
+
+
+ }
+ collapsed={isCollapsed}
+ />
+ {!isCollapsed && (
+
+ PRO
+
+ )}
+
+
+
+
+
+ {newsletter.title}
+
+
+
+
+
+ {new Date(newsletter.date).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
+
+
+
+
+ {newsletter.readTime}
+
+
+
+ {newsletter.excerpt}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/newsletters/NewsletterFilters.tsx b/apps/web/src/components/newsletters/NewsletterFilters.tsx
new file mode 100644
index 0000000..9b6b630
--- /dev/null
+++ b/apps/web/src/components/newsletters/NewsletterFilters.tsx
@@ -0,0 +1,57 @@
+import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
+import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
+
+export type TimeFilter = "all" | "day" | "week" | "month";
+
+interface NewsletterFiltersProps {
+ searchQuery: string;
+ onSearchChange: (query: string) => void;
+ timeFilter: TimeFilter;
+ onTimeFilterChange: (filter: TimeFilter) => void;
+}
+
+export function NewsletterFilters({
+ searchQuery,
+ onSearchChange,
+ timeFilter,
+ onTimeFilterChange,
+}: NewsletterFiltersProps) {
+ const filters: { value: TimeFilter; label: string }[] = [
+ { value: "all", label: "All Time" },
+ { value: "day", label: "Today" },
+ { value: "week", label: "This Week" },
+ { value: "month", label: "This Month" },
+ ];
+
+ return (
+
+
+
+
+
+ onSearchChange(e.target.value)}
+ />
+
+
+
+ {filters.map((filter) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/newsletters/NewsletterList.tsx b/apps/web/src/components/newsletters/NewsletterList.tsx
new file mode 100644
index 0000000..1f713f4
--- /dev/null
+++ b/apps/web/src/components/newsletters/NewsletterList.tsx
@@ -0,0 +1,40 @@
+import { NewsletterCard, Newsletter } from "./NewsletterCard";
+import { NewsletterSkeleton } from "./NewsletterSkeleton";
+
+interface NewsletterListProps {
+ newsletters: Newsletter[];
+ loading: boolean;
+ hasFilters: boolean;
+}
+
+export function NewsletterList({ newsletters, loading, hasFilters }: NewsletterListProps) {
+ if (loading) {
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+
+ if (newsletters.length === 0) {
+ return (
+
+
+ {hasFilters
+ ? "No newsletters found matching your criteria."
+ : "No newsletters available yet."}
+
+
+ );
+ }
+
+ return (
+ <>
+ {newsletters.map((newsletter) => (
+
+ ))}
+ >
+ );
+}
diff --git a/apps/web/src/components/newsletters/NewsletterPagination.tsx b/apps/web/src/components/newsletters/NewsletterPagination.tsx
new file mode 100644
index 0000000..8b6071f
--- /dev/null
+++ b/apps/web/src/components/newsletters/NewsletterPagination.tsx
@@ -0,0 +1,89 @@
+import {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
+
+interface NewsletterPaginationProps {
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}
+
+export function NewsletterPagination({
+ currentPage,
+ totalPages,
+ onPageChange,
+}: NewsletterPaginationProps) {
+ if (totalPages <= 1) return null;
+
+ const getPageNumbers = () => {
+ const pages: (number | string)[] = [];
+ const maxVisible = 5;
+
+ if (totalPages <= maxVisible) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
+ }
+
+ pages.push(1);
+
+ if (currentPage > 3) {
+ pages.push("ellipsis-start");
+ }
+
+ const start = Math.max(2, currentPage - 1);
+ const end = Math.min(totalPages - 1, currentPage + 1);
+
+ for (let i = start; i <= end; i++) {
+ pages.push(i);
+ }
+
+ if (currentPage < totalPages - 2) {
+ pages.push("ellipsis-end");
+ }
+
+ pages.push(totalPages);
+
+ return pages;
+ };
+
+ return (
+
+
+
+ onPageChange(Math.max(1, currentPage - 1))}
+ className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
+ />
+
+
+ {getPageNumbers().map((page, index) => (
+
+ {typeof page === "number" ? (
+ onPageChange(page)}
+ isActive={currentPage === page}
+ className="cursor-pointer"
+ >
+ {page}
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ onPageChange(Math.min(totalPages, currentPage + 1))}
+ className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/components/newsletters/NewsletterSkeleton.tsx b/apps/web/src/components/newsletters/NewsletterSkeleton.tsx
new file mode 100644
index 0000000..4f4f4b0
--- /dev/null
+++ b/apps/web/src/components/newsletters/NewsletterSkeleton.tsx
@@ -0,0 +1,15 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+export function NewsletterSkeleton() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx b/apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx
new file mode 100644
index 0000000..b1d6745
--- /dev/null
+++ b/apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { SparklesIcon, LockClosedIcon } from "@heroicons/react/24/outline";
+import PrimaryButton from "@/components/ui/custom-button";
+
+export function PremiumUpgradePrompt() {
+ const router = useRouter();
+
+ return (
+
+
+
+
+
+ OX Newsletter
+
+
+
+ Stay ahead in the open source world. Get curated insights on jobs, funding news, trending projects, upcoming trends, and expert tips.
+
+
+
router.push("/pricing")}
+ classname="w-full px-6"
+ >
+
+ Unlock Premium
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..0777399
--- /dev/null
+++ b/apps/web/src/components/ui/input-group.tsx
@@ -0,0 +1,170 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ textarea]:h-auto",
+
+ // Variants based on alignment.
+ "has-[>[data-align=inline-start]]:[&>input]:pl-2",
+ "has-[>[data-align=inline-end]]:[&>input]:pr-2",
+ "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
+ "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
+
+ // Focus state.
+ "has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
+
+ // Error state.
+ "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
+
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const inputGroupAddonVariants = cva(
+ "text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ "inline-start":
+ "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
+ "inline-end":
+ "order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
+ "block-start":
+ "[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
+ "block-end":
+ "[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ }
+)
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus()
+ }}
+ {...props}
+ />
+ )
+}
+
+const inputGroupButtonVariants = cva(
+ "flex items-center gap-2 text-sm shadow-none",
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
+ "icon-xs":
+ "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+ }
+)
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size"> &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
new file mode 100644
index 0000000..69b64fb
--- /dev/null
+++ b/apps/web/src/components/ui/input.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..d331105
--- /dev/null
+++ b/apps/web/src/components/ui/pagination.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { ButtonProps, buttonVariants } from "@/components/ui/button"
+
+const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
+
+)
+Pagination.displayName = "Pagination"
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationContent.displayName = "PaginationContent"
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationItem.displayName = "PaginationItem"
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick &
+ React.ComponentProps<"a">
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) => (
+
+)
+PaginationLink.displayName = "PaginationLink"
+
+const PaginationPrevious = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+
+ Previous
+
+)
+PaginationPrevious.displayName = "PaginationPrevious"
+
+const PaginationNext = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+ Next
+
+
+)
+PaginationNext.displayName = "PaginationNext"
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More pages
+
+)
+PaginationEllipsis.displayName = "PaginationEllipsis"
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+}
diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..d7e45f7
--- /dev/null
+++ b/apps/web/src/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..e56b0af
--- /dev/null
+++ b/apps/web/src/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/apps/web/src/content/newsletters/2024-01-welcome.md b/apps/web/src/content/newsletters/2024-01-welcome.md
new file mode 100644
index 0000000..0474c1c
--- /dev/null
+++ b/apps/web/src/content/newsletters/2024-01-welcome.md
@@ -0,0 +1,31 @@
+---
+title: "Welcome to Opensox AI - Your Journey Begins"
+date: "2025-01-15"
+excerpt: "Introducing Opensox AI, the revolutionary platform that helps developers find the perfect open-source projects to contribute to in minutes."
+readTime: "3 min read"
+---
+
+# Welcome to Opensox AI
+
+We're thrilled to have you here! **Opensox AI** is designed to transform how developers discover and contribute to open-source projects.
+
+## What Makes Us Different?
+
+- **AI-Powered Matching**: Our intelligent system analyzes your skills and interests
+- **Curated Projects**: Every project is handpicked for quality and community
+- **Quick Discovery**: Find your perfect match in under 10 minutes
+
+## Getting Started
+
+1. Complete your profile with your skills
+2. Browse our curated project list
+3. Start contributing today!
+
+
+
+### Join Our Community
+
+Connect with thousands of developers on our [Discord server](https://discord.gg/37ke8rYnRM) and share your journey.
+
+**Happy Contributing!**
+The Opensox Team
diff --git a/apps/web/src/content/newsletters/2024-02-new-features.md b/apps/web/src/content/newsletters/2024-02-new-features.md
new file mode 100644
index 0000000..a184b65
--- /dev/null
+++ b/apps/web/src/content/newsletters/2024-02-new-features.md
@@ -0,0 +1,165 @@
+---
+title: "opensox newsletter demo - full formatting test"
+date: "2025-06-10"
+excerpt: "a stress test markdown file to validate rendering, spacing, images, lists, code, tables, and odd content."
+readTime: "7 min read"
+---
+
+# full formatting test
+
+welcome to the **opensox formatting demo**.
+this file tests every tricky markdown case that your newsletter system might hit.
+
+## headings
+
+### h3 heading
+#### h4 heading
+##### h5 heading
+
+---
+
+## paragraphs and breaks
+
+this is a normal paragraph to test spacing and line height.
+
+here is another paragraph to see if margins are correct.
+
+this is a line with
+a manual line break
+to test `
` handling.
+
+---
+
+## bold, italics, links
+
+this is **bold text**.
+this is *italic text*.
+this is **bold and *nested italic***.
+this is a [link to opensox](https://opensox.ai).
+
+---
+
+## images
+
+
+
+
+
+both of these should render cleanly.
+
+---
+
+## lists
+
+### unordered list
+
+- item one
+- item two
+ - sub item a
+ - sub item b
+ - deep sub item
+
+### ordered list
+
+1. first
+2. second
+3. third
+ 1. nested a
+ 2. nested b
+
+---
+
+## blockquote
+
+> this is a quote block.
+> it should have left padding and a border.
+
+---
+
+## code blocks
+
+### inline code
+
+here is some inline code: `npm install opensox`.
+
+### fenced code
+
+```ts
+export function example() {
+ console.log("hello from opensox");
+ return { ok: true };
+}
+````
+
+### long code block
+
+```json
+{
+ "project": "opensox-ai",
+ "features": [
+ "ai search",
+ "project tags",
+ "user onboarding"
+ ],
+ "version": "1.0.0"
+}
+```
+
+---
+
+## table
+
+| feature | status | notes |
+| ----------- | -------- | ------------------------- |
+| ai matching | live | powered by smart scoring |
+| onboarding | improved | new user flows added |
+| news feed | coming | planned for march release |
+
+---
+
+## inline html test
+
+
+this is inline html.
+your renderer should not break when it sees light html.
+
+
+---
+
+## horizontal rules
+
+---
+
+another section after hr.
+
+---
+
+## weird characters test
+
+quotes: "hello", 'hi'
+symbols: © ® ™ ∞ ≈ ± ÷
+punctuation: … · • ° ¶ §
+
+(no em-dashes used, only hyphens)
+
+---
+
+## long paragraph stress test
+
+this is a deliberately long paragraph that exists only to test line wrapping, max width constraints, and readability under a large continuous block of text without breaks. your ui should not collapse, overflow horizontally, or produce awkward spacing when the text becomes extremely long. this type of paragraph commonly appears in newsletter intros, community messages, and deep write ups. verifying its behavior now will save you from unexpected layout issues later in production environments where user generated content appears.
+
+---
+
+## links with titles
+
+[opensox homepage](https://opensox.ai "opensox official site")
+
+---
+
+## ending message
+
+thanks for reading this giant formatting demo.
+you can delete it after testing.
+
+**the opensox team**
+
diff --git a/apps/web/src/content/newsletters/2024-03-success-stories.md b/apps/web/src/content/newsletters/2024-03-success-stories.md
new file mode 100644
index 0000000..df85a86
--- /dev/null
+++ b/apps/web/src/content/newsletters/2024-03-success-stories.md
@@ -0,0 +1,58 @@
+---
+title: "Community Success Stories: March Edition"
+date: "2025-11-08"
+excerpt: "Hear inspiring stories from developers who landed their dream jobs through open-source contributions made via Opensox AI."
+readTime: "5 min read"
+---
+
+# Success Stories That Inspire Us
+
+This month, we're celebrating the **amazing achievements** of our community members who transformed their careers through open-source.
+
+## Featured Story: From Contributor to Maintainer
+
+**Alex Thompson** started contributing to a React UI library through Opensox AI last year. Today, Alex is a core maintainer of the project with over 200 contributions.
+
+> "Opensox helped me find projects that matched my skill level perfectly. The journey from first PR to maintainer was incredible!" - Alex
+
+## By The Numbers
+
+- **12,000+** projects matched
+- **8,500+** successful contributions
+- **250+** developers hired
+- **95%** satisfaction rate
+
+## Tips for Success
+
+### 1. Start Small
+Begin with "good first issue" tags to build confidence.
+
+### 2. Be Consistent
+Regular contributions matter more than large ones.
+
+### 3. Engage with Community
+Ask questions, help others, and build relationships.
+
+## Upcoming Events
+
+📅 **Open Source Workshop** - March 20th
+Learn best practices for contributing to large projects.
+
+📅 **Community Meetup** - March 28th
+Network with fellow contributors (virtual).
+
+## Premium Launch
+
+We're excited to announce **Opensox Premium** launching next month!
+
+Benefits include:
+- Priority project matching
+- Advanced analytics dashboard
+- 1-on-1 mentorship sessions
+- Early access to new features
+
+---
+
+**Keep contributing, keep growing!**
+
+[Share your story](mailto:hi@opensox.ai) | [Join Discord](https://discord.gg/37ke8rYnRM) | [Follow on X](https://x.com/ajeetunc)
diff --git a/apps/web/src/hooks/useNewsletterFilters.ts b/apps/web/src/hooks/useNewsletterFilters.ts
new file mode 100644
index 0000000..b332b0e
--- /dev/null
+++ b/apps/web/src/hooks/useNewsletterFilters.ts
@@ -0,0 +1,50 @@
+import { useMemo } from "react";
+import { Newsletter } from "@/components/newsletters/NewsletterCard";
+import { TimeFilter } from "@/components/newsletters/NewsletterFilters";
+
+export function useNewsletterFilters(
+ newsletters: Newsletter[],
+ searchQuery: string,
+ timeFilter: TimeFilter
+) {
+ return useMemo(() => {
+ let filtered = newsletters;
+
+ // Search filter
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ filtered = filtered.filter(
+ (newsletter) =>
+ newsletter.title.toLowerCase().includes(query) ||
+ newsletter.excerpt.toLowerCase().includes(query)
+ );
+ }
+
+ // Time filter
+ if (timeFilter !== "all") {
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+
+ filtered = filtered.filter((newsletter) => {
+ const newsletterDate = new Date(newsletter.date);
+
+ switch (timeFilter) {
+ case "day":
+ return newsletterDate >= today;
+ case "week":
+ const weekAgo = new Date(today);
+ weekAgo.setDate(weekAgo.getDate() - 7);
+ return newsletterDate >= weekAgo;
+ case "month":
+ const monthAgo = new Date(today);
+ monthAgo.setMonth(monthAgo.getMonth() - 1);
+ return newsletterDate >= monthAgo;
+ default:
+ return true;
+ }
+ });
+ }
+
+ return filtered;
+ }, [newsletters, searchQuery, timeFilter]);
+}
diff --git a/apps/web/src/styles/newsletter.css b/apps/web/src/styles/newsletter.css
new file mode 100644
index 0000000..25849ff
--- /dev/null
+++ b/apps/web/src/styles/newsletter.css
@@ -0,0 +1,117 @@
+/* Newsletter / markdown content styling */
+.newsletter-content {
+ @apply break-words text-sm sm:text-base text-zinc-300 leading-relaxed;
+}
+
+.newsletter-content h1 {
+ @apply text-2xl sm:text-3xl font-bold text-ox-white mt-6 sm:mt-8 mb-3 sm:mb-4 break-words;
+}
+
+.newsletter-content h2 {
+ @apply text-xl sm:text-2xl font-semibold text-ox-white mt-5 sm:mt-6 mb-2 sm:mb-3 break-words;
+}
+
+.newsletter-content h3 {
+ @apply text-lg sm:text-xl font-semibold text-zinc-200 mt-4 mb-2 break-words;
+}
+
+.newsletter-content h4 {
+ @apply text-base sm:text-lg font-semibold text-zinc-200 mt-3 mb-2 break-words;
+}
+
+.newsletter-content p {
+ @apply mb-4 break-words;
+}
+
+.newsletter-content strong {
+ @apply text-ox-white font-semibold;
+}
+
+.newsletter-content em {
+ @apply italic;
+}
+
+.newsletter-content ul,
+.newsletter-content ol {
+ @apply mb-4 ml-4 sm:ml-6 space-y-2 text-zinc-300;
+}
+
+.newsletter-content ul {
+ list-style-type: disc;
+}
+
+.newsletter-content ol {
+ list-style-type: decimal;
+}
+
+.newsletter-content li {
+ @apply break-words;
+}
+
+.newsletter-content a {
+ @apply text-ox-purple hover:text-purple-400 underline transition-colors break-all;
+}
+
+/* Make long URLs not break layout */
+.newsletter-content p>a,
+.newsletter-content li>a {
+ @apply inline-block max-w-full;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
+ hyphens: auto;
+}
+
+.newsletter-content img {
+ @apply rounded-lg my-4 sm:my-6 w-full h-auto max-w-full object-contain;
+ max-height: 500px;
+}
+
+/* GIFs behave like images */
+.newsletter-content img[src*=".gif"],
+.newsletter-content img[alt*="gif"] {
+ @apply object-cover;
+}
+
+.newsletter-content blockquote {
+ @apply border-l-4 border-ox-purple pl-3 sm:pl-4 italic text-sm sm:text-base text-zinc-400 my-4 break-words;
+}
+
+.newsletter-content hr {
+ @apply border-[#1a1a1d] my-6 sm:my-8;
+}
+
+.newsletter-content code {
+ @apply bg-[#121214] text-ox-purple px-1.5 sm:px-2 py-0.5 sm:py-1 rounded text-xs sm:text-sm break-all;
+}
+
+/* Code blocks */
+.newsletter-content pre {
+ @apply bg-[#121214] p-3 sm:p-4 rounded-lg overflow-x-auto my-4 text-xs sm:text-sm;
+}
+
+.newsletter-content pre code {
+ @apply bg-transparent p-0 text-xs sm:text-sm;
+}
+
+/* Tables */
+.newsletter-content table {
+ @apply w-full border-collapse my-4 text-sm sm:text-base;
+}
+
+.newsletter-content thead {
+ @apply bg-[#121214];
+}
+
+.newsletter-content th,
+.newsletter-content td {
+ @apply border border-[#1a1a1d] px-3 py-2 align-top;
+}
+
+.newsletter-content th {
+ @apply font-semibold text-ox-white;
+}
+
+.newsletter-content tbody tr:nth-child(even) {
+ @apply bg-[#0f0f11];
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8b60240..4adbdfe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -146,7 +146,7 @@ importers:
specifier: ^1.2.1
version: 1.3.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot':
- specifier: ^1.1.0
+ specifier: ^1.2.3
version: 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@tanstack/react-query':
specifier: ^5.90.2
@@ -178,9 +178,15 @@ importers:
geist:
specifier: ^1.5.1
version: 1.5.1(next@15.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
+ gray-matter:
+ specifier: ^4.0.3
+ version: 4.0.3
lucide-react:
specifier: ^0.456.0
version: 0.456.0(react@18.3.1)
+ marked:
+ specifier: ^17.0.0
+ version: 17.0.0
next:
specifier: 15.5.3
version: 15.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -2656,6 +2662,10 @@ packages:
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
engines: {node: '>= 0.10.0'}
+ extend-shallow@2.0.1:
+ resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
+ engines: {node: '>=0.10.0'}
+
external-editor@3.1.0:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'}
@@ -2896,6 +2906,10 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+ gray-matter@4.0.3:
+ resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
+ engines: {node: '>=6.0'}
+
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
@@ -3068,6 +3082,10 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
+ is-extendable@0.1.1:
+ resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
+ engines: {node: '>=0.10.0'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -3273,6 +3291,10 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ kind-of@6.0.3:
+ resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
+ engines: {node: '>=0.10.0'}
+
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -3373,6 +3395,11 @@ packages:
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
+ marked@17.0.0:
+ resolution: {integrity: sha512-KkDYEWEEiYJw/KC+DVm1zzlpMQSMIu6YRltkcCvwheCp8HWPXCk9JwOmHJKBlGfzcpzcIt6x3sMnTsRm/51oDg==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -4057,6 +4084,10 @@ packages:
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+ section-matter@1.0.0:
+ resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
+ engines: {node: '>=4'}
+
semver-compare@1.0.0:
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
@@ -4247,6 +4278,10 @@ packages:
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
engines: {node: '>=12'}
+ strip-bom-string@1.0.0:
+ resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
+ engines: {node: '>=0.10.0'}
+
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -4377,6 +4412,9 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ tr46@0.0.3:
+ resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+
ts-api-utils@1.4.3:
resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==}
engines: {node: '>=16'}
@@ -7391,6 +7429,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extend-shallow@2.0.1:
+ dependencies:
+ is-extendable: 0.1.1
+
external-editor@3.1.0:
dependencies:
chardet: 0.7.0
@@ -7674,6 +7716,13 @@ snapshots:
graphemer@1.4.0: {}
+ gray-matter@4.0.3:
+ dependencies:
+ js-yaml: 3.14.1
+ kind-of: 6.0.3
+ section-matter: 1.0.0
+ strip-bom-string: 1.0.0
+
handlebars@4.7.8:
dependencies:
minimist: 1.2.8
@@ -7876,6 +7925,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-extendable@0.1.1: {}
+
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -8074,6 +8125,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ kind-of@6.0.3: {}
+
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -8158,6 +8211,8 @@ snapshots:
make-error@1.3.6: {}
+ marked@17.0.0: {}
+
math-intrinsics@1.1.0: {}
media-typer@0.3.0: {}
@@ -8875,6 +8930,11 @@ snapshots:
dependencies:
loose-envify: 1.4.0
+ section-matter@1.0.0:
+ dependencies:
+ extend-shallow: 2.0.1
+ kind-of: 6.0.3
+
semver-compare@1.0.0: {}
semver@5.7.2: {}
@@ -9152,6 +9212,8 @@ snapshots:
dependencies:
ansi-regex: 6.1.0
+ strip-bom-string@1.0.0: {}
+
strip-bom@3.0.0: {}
strip-final-newline@2.0.0: {}
@@ -9285,6 +9347,8 @@ snapshots:
toidentifier@1.0.1: {}
+ tr46@0.0.3: {}
+
ts-api-utils@1.4.3(typescript@5.9.2):
dependencies:
typescript: 5.9.2