- {SIDEBAR_ROUTES.map((route) => {
+
+ {/* free features section */}
+ {FREE_ROUTES.map((route) => {
+ const isActive =
+ pathname === route.path || pathname.startsWith(`${route.path}/`);
return (
-
+
+
+ {route.icon}
+
+ {!isCollapsed && (
+
+
+ {route.label}
+
+ {route.badge && (
+
+ {route.badge}
+
+ )}
+
+ )}
+
);
})}
+
+ {/* divider */}
+ {!isCollapsed && (
+
+ )}
+
+ {/* premium section */}
+ {!isCollapsed ? (
+
+ {(() => {
+ const isPremiumRouteActive = PREMIUM_ROUTES.some(
+ (route) =>
+ pathname === route.path ||
+ pathname.startsWith(`${route.path}/`)
+ );
+ const newFeaturesCount = PREMIUM_ROUTES.filter(
+ (route) => route.badge
+ ).length;
+ return (
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleProSectionClick();
+ }
+ }}
+ >
+
+
+
+
+
+
+ Opensox Pro
+
+
+ {newFeaturesCount > 0 && (
+
+ {newFeaturesCount}
+
+ )}
+
+
+ {isPaidUser && (
+
+ )}
+
+ );
+ })()}
+
+ {/* premium sub-items (only show if paid user and expanded) */}
+ {isPaidUser && proSectionExpanded && (
+
+ {PREMIUM_ROUTES.map((route) => {
+ const isActive =
+ pathname === route.path ||
+ pathname.startsWith(`${route.path}/`);
+ return (
+
+
+
+ {route.icon}
+
+
+
+ {route.label}
+
+ {route.badge && (
+
+ {route.badge}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* free user: show locked preview */}
+ {!isPaidUser && (
+
+ {PREMIUM_ROUTES.map((route) => (
+ router.push("/pricing")}
+ className="w-full h-[44px] flex items-center rounded-md cursor-pointer transition-colors px-2 gap-3 opacity-50 hover:opacity-75 group"
+ role="button"
+ tabIndex={0}
+ aria-label={`${route.label} - Upgrade to Pro`}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ router.push("/pricing");
+ }
+ }}
+ >
+
+ {route.icon}
+
+
+
+ {route.label}
+
+ {route.badge && (
+
+ {route.badge}
+
+ )}
+
+
+
+
+
+ ))}
+
+ )}
+
+ ) : (
+ // collapsed sidebar: show icon only
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleProSectionClick();
+ }
+ }}
+ >
+
+
+ )}
+
+ {/* divider */}
+ {!isCollapsed && (
+
+ )}
+
+ {/* utility features */}
}
collapsed={isCollapsed}
/>
- {!isCollapsed && !isPaidUser ? (
-
-
-
-
-
-
- Opensox Pro
-
-
-
-
- ) : (
-
}
- collapsed={isCollapsed}
- />
- )}
{/* Bottom profile */}
diff --git a/apps/web/src/components/newsletters/NewsletterCard.tsx b/apps/web/src/components/newsletters/NewsletterCard.tsx
new file mode 100644
index 00000000..4c5be3a8
--- /dev/null
+++ b/apps/web/src/components/newsletters/NewsletterCard.tsx
@@ -0,0 +1,39 @@
+import Link from "next/link";
+
+export interface Newsletter {
+ id: string;
+ title: string;
+ description: string;
+ excerpt?: string;
+ date: string;
+ readTime: string;
+ tags?: string[];
+}
+
+export function NewsletterCard({ newsletter }: { newsletter: Newsletter }) {
+ return (
+
+
+
+
+
+ {newsletter.title}
+
+
+ {newsletter.excerpt || newsletter.description}
+
+
+
+
{newsletter.date}
+
+
+ {newsletter.readTime}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/newsletters/NewsletterFilters.tsx b/apps/web/src/components/newsletters/NewsletterFilters.tsx
new file mode 100644
index 00000000..024003ff
--- /dev/null
+++ b/apps/web/src/components/newsletters/NewsletterFilters.tsx
@@ -0,0 +1,68 @@
+import { Search } from "lucide-react";
+
+export type TimeFilter = "all" | "january" | "february" | "march" | "april" | "may" | "june" | "july" | "august" | "september" | "october" | "november" | "december";
+export type SortFilter = "newest" | "oldest";
+
+interface NewsletterFiltersProps {
+ searchQuery: string;
+ onSearchChange: (query: string) => void;
+ timeFilter: TimeFilter;
+ onTimeFilterChange: (filter: TimeFilter) => void;
+ sortFilter?: SortFilter;
+ onSortFilterChange?: (filter: SortFilter) => void;
+}
+
+export function NewsletterFilters({
+ searchQuery,
+ onSearchChange,
+ timeFilter,
+ onTimeFilterChange,
+ sortFilter = "newest",
+ onSortFilterChange,
+}: NewsletterFiltersProps) {
+ return (
+
+
+
+ onSearchChange(e.target.value)}
+ className="w-full bg-[#111111] border border-zinc-800 rounded-lg pl-11 pr-4 py-2.5 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:border-zinc-700 focus:ring-1 focus:ring-zinc-700 transition-all"
+ />
+
+
+
+
+ {onSortFilterChange && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/newsletters/NewsletterList.tsx b/apps/web/src/components/newsletters/NewsletterList.tsx
new file mode 100644
index 00000000..ef37c431
--- /dev/null
+++ b/apps/web/src/components/newsletters/NewsletterList.tsx
@@ -0,0 +1,38 @@
+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 filters." : "No newsletters available yet."}
+
+
+ );
+ }
+
+ return (
+
+ {newsletters.map((newsletter, index) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/newsletters/NewsletterPagination.tsx b/apps/web/src/components/newsletters/NewsletterPagination.tsx
new file mode 100644
index 00000000..8b6071fc
--- /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 00000000..d2e8bb15
--- /dev/null
+++ b/apps/web/src/components/newsletters/NewsletterSkeleton.tsx
@@ -0,0 +1,19 @@
+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 00000000..b1d67457
--- /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 00000000..07773992
--- /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 00000000..69b64fb2
--- /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 00000000..d3311054
--- /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 00000000..d7e45f7b
--- /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 00000000..e56b0aff
--- /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-premium b/apps/web/src/content/newsletters-premium
new file mode 160000
index 00000000..0f49f26e
--- /dev/null
+++ b/apps/web/src/content/newsletters-premium
@@ -0,0 +1 @@
+Subproject commit 0f49f26e2a0c07d9765786b0d44754cc586e234f
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 00000000..a19c8b79
--- /dev/null
+++ b/apps/web/src/content/newsletters/2024-01-welcome.md
@@ -0,0 +1,47 @@
+---
+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.
+
+---
+
+## Want More?
+
+This is a free sample newsletter. **Premium subscribers** get exclusive access to:
+
+- 📰 **Weekly Curated Insights** on open-source jobs and opportunities
+- 💰 **Funding News** and investment trends in the OSS ecosystem
+- 🚀 **Trending Projects** before they go viral
+- 💡 **Expert Tips** from maintainers and core contributors
+- 📊 **Industry Analysis** and deep dives into OSS trends
+
+[**Upgrade to Premium**](/pricing) to unlock the full newsletter archive and stay ahead in the open-source world.
+
+---
+
+**Happy Contributing!**
+The Opensox Team
diff --git a/apps/web/src/hooks/useNewsletterFilters.ts b/apps/web/src/hooks/useNewsletterFilters.ts
new file mode 100644
index 00000000..824a11de
--- /dev/null
+++ b/apps/web/src/hooks/useNewsletterFilters.ts
@@ -0,0 +1,35 @@
+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];
+
+ // Apply search filter
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ filtered = filtered.filter(
+ (newsletter) =>
+ newsletter.title.toLowerCase().includes(query) ||
+ newsletter.description?.toLowerCase().includes(query) ||
+ newsletter.excerpt?.toLowerCase().includes(query)
+ );
+ }
+
+ // Apply time filter
+ if (timeFilter !== "all") {
+ filtered = filtered.filter((newsletter) => {
+ const date = new Date(newsletter.date);
+ const month = date.toLocaleString("en-US", { month: "long" }).toLowerCase();
+ return month === timeFilter;
+ });
+ }
+
+ return filtered;
+ }, [newsletters, searchQuery, timeFilter]);
+}
diff --git a/apps/web/src/lib/trpc-server.ts b/apps/web/src/lib/trpc-server.ts
index 312bb362..fb8cebbd 100644
--- a/apps/web/src/lib/trpc-server.ts
+++ b/apps/web/src/lib/trpc-server.ts
@@ -1,6 +1,7 @@
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "../../../api/src/routers/_app";
+import type { Session } from "next-auth";
/**
* Server-side tRPC client for use in NextAuth callbacks and server components
@@ -16,3 +17,26 @@ export const serverTrpc = createTRPCProxyClient({
}),
],
});
+
+/**
+ * Create a tRPC client with session authentication
+ */
+export function createAuthenticatedClient(session: Session) {
+ return createTRPCProxyClient({
+ links: [
+ httpBatchLink({
+ transformer: superjson,
+ url: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"}/trpc`,
+ headers() {
+ const token = session.accessToken;
+ if (token) {
+ return {
+ authorization: `Bearer ${token}`,
+ };
+ }
+ return {};
+ },
+ }),
+ ],
+ });
+}
diff --git a/apps/web/src/styles/newsletter.css b/apps/web/src/styles/newsletter.css
new file mode 100644
index 00000000..810bd6cf
--- /dev/null
+++ b/apps/web/src/styles/newsletter.css
@@ -0,0 +1,195 @@
+/* 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-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-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-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-white;
+}
+
+.newsletter-content tbody tr:nth-child(even) {
+ @apply bg-[#0f0f11];
+}
+
+/* Enhanced Newsletter Content Styles */
+.newsletter-content-wrapper {
+ position: relative;
+ background: linear-gradient(to bottom, transparent 0%, rgba(139, 92, 246, 0.02) 50%, transparent 100%);
+ padding: 2rem 0;
+ margin: 0 -1rem;
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+@media (min-width: 640px) {
+ .newsletter-content-wrapper {
+ margin: 0 -2rem;
+ padding-left: 2rem;
+ padding-right: 2rem;
+ }
+}
+
+/* Minimal Newsletter Content Styles */
+.newsletter-content h2 {
+ position: relative;
+
+}
+
+.newsletter-content img {
+ margin-top: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.newsletter-content ul,
+.newsletter-content ol {
+ margin-left: 1.25rem;
+}
+
+.newsletter-content li::marker {
+ color: rgb(139, 92, 246);
+}
+
+.newsletter-content table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 1.5rem 0;
+}
+
+.newsletter-content th,
+.newsletter-content td {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ font-size: 0.875rem;
+}
+
+.newsletter-content th {
+ background: rgba(139, 92, 246, 0.1);
+ color: white;
+ font-weight: 500;
+}
+
+.newsletter-content td {
+ color: rgb(212, 212, 216);
+}
+
+/* Smooth reading experience */
+.newsletter-article {
+ animation: fadeIn 0.5s ease-in;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e6fa2dbc..97d7f4b9 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
@@ -181,9 +181,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)
@@ -221,6 +227,9 @@ importers:
specifier: ^5.0.1
version: 5.0.5(@types/react@18.3.23)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1))
devDependencies:
+ '@tailwindcss/line-clamp':
+ specifier: ^0.4.4
+ version: 0.4.4(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2)))
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
@@ -1221,6 +1230,11 @@ packages:
'@swc/helpers@0.5.5':
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
+ '@tailwindcss/line-clamp@0.4.4':
+ resolution: {integrity: sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==}
+ peerDependencies:
+ tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
+
'@tanstack/query-core@5.90.2':
resolution: {integrity: sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==}
@@ -2672,6 +2686,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'}
@@ -2912,6 +2930,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'}
@@ -3084,6 +3106,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'}
@@ -3289,6 +3315,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==}
@@ -3389,6 +3419,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'}
@@ -4073,6 +4108,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==}
@@ -4263,6 +4302,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'}
@@ -5517,6 +5560,10 @@ snapshots:
'@swc/counter': 0.1.3
tslib: 2.8.1
+ '@tailwindcss/line-clamp@0.4.4(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2)))':
+ dependencies:
+ tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2))
+
'@tanstack/query-core@5.90.2': {}
'@tanstack/react-query@5.90.2(react@18.3.1)':
@@ -6948,7 +6995,7 @@ snapshots:
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1)
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
@@ -6967,8 +7014,8 @@ snapshots:
'@typescript-eslint/parser': 8.34.0(eslint@8.57.1)(typescript@5.9.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -7001,21 +7048,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1):
- dependencies:
- '@nolyfill/is-core-module': 1.0.39
- debug: 4.4.1
- eslint: 8.57.1
- get-tsconfig: 4.10.1
- is-bun-module: 2.0.0
- stable-hash: 0.0.5
- tinyglobby: 0.2.14
- unrs-resolver: 1.9.0
- optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
- transitivePeerDependencies:
- - supports-color
-
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
@@ -7027,7 +7059,7 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.9.0
optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@@ -7042,7 +7074,7 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.9.0
optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@@ -7067,14 +7099,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
+ eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.34.0(eslint@8.57.1)(typescript@5.9.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@@ -7113,7 +7145,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -7142,7 +7174,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -7153,7 +7185,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -7421,6 +7453,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
@@ -7704,6 +7740,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
@@ -7906,6 +7949,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:
@@ -8104,6 +8149,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ kind-of@6.0.3: {}
+
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -8188,6 +8235,8 @@ snapshots:
make-error@1.3.6: {}
+ marked@17.0.0: {}
+
math-intrinsics@1.1.0: {}
media-typer@0.3.0: {}
@@ -8905,6 +8954,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: {}
@@ -9182,6 +9236,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: {}