diff --git a/graph/ui-graph/src/lib/nx-project-graph-viz.tsx b/graph/ui-graph/src/lib/nx-project-graph-viz.tsx index 3adcced829..e415a2c99c 100644 --- a/graph/ui-graph/src/lib/nx-project-graph-viz.tsx +++ b/graph/ui-graph/src/lib/nx-project-graph-viz.tsx @@ -1,3 +1,4 @@ +'use client'; /* eslint-disable @nx/enforce-module-boundaries */ /* nx-ignore-next-line */ import type { diff --git a/graph/ui-graph/src/lib/nx-task-graph-viz.tsx b/graph/ui-graph/src/lib/nx-task-graph-viz.tsx index 8c79ebfb27..42b140c721 100644 --- a/graph/ui-graph/src/lib/nx-task-graph-viz.tsx +++ b/graph/ui-graph/src/lib/nx-task-graph-viz.tsx @@ -1,3 +1,4 @@ +'use client'; /* eslint-disable @nx/enforce-module-boundaries */ /* nx-ignore-next-line */ import type { ProjectGraphProjectNode } from 'nx/src/config/project-graph'; diff --git a/nx-dev/data-access-documents/src/lib/blog.api.ts b/nx-dev/data-access-documents/src/lib/blog.api.ts index a2d75467f1..fcf9b41dcb 100644 --- a/nx-dev/data-access-documents/src/lib/blog.api.ts +++ b/nx-dev/data-access-documents/src/lib/blog.api.ts @@ -3,6 +3,7 @@ import { join, basename } from 'path'; import { extractFrontmatter } from '@nx/nx-dev/ui-markdoc'; import { sortPosts } from './blog.util'; import { BlogPostDataEntry } from './blog.model'; +import { readFile, readdir } from 'fs/promises'; export class BlogApi { constructor( @@ -19,6 +20,43 @@ export class BlogApi { } } + async getBlogs(): Promise { + const files: string[] = await readdir(this.options.blogRoot); + const authors = JSON.parse( + readFileSync(join(this.options.blogRoot, 'authors.json'), 'utf8') + ); + const allPosts: BlogPostDataEntry[] = []; + + for (const file of files) { + const filePath = join(this.options.blogRoot, file); + if (!filePath.endsWith('.md')) continue; + const content = await readFile(filePath, 'utf8'); + const frontmatter = extractFrontmatter(content); + const slug = this.calculateSlug(filePath, frontmatter); + const post = { + content, + title: frontmatter.title ?? null, + description: frontmatter.description ?? null, + authors: authors.filter((author) => + frontmatter.authors.includes(author.name) + ), + date: this.calculateDate(file, frontmatter), + cover_image: frontmatter.cover_image + ? `/documentation${frontmatter.cover_image}` // Match the prefix used by markdown parser + : null, + tags: frontmatter.tags ?? [], + reposts: frontmatter.reposts ?? [], + pinned: frontmatter.pinned ?? false, + filePath, + slug, + }; + if (!frontmatter.draft || process.env.NODE_ENV === 'development') { + allPosts.push(post); + } + } + return sortPosts(allPosts); + } + getBlogPosts(): BlogPostDataEntry[] { const files: string[] = readdirSync(this.options.blogRoot); const authors = JSON.parse( @@ -68,6 +106,13 @@ export class BlogApi { } return blog; } + // Optimize this so we don't read the FS multiple times + async getBlogPostBySlug( + slug: string | null + ): Promise { + if (!slug) throw new Error(`Could not find blog post with slug: ${slug}`); + return (await this.getBlogs()).find((post) => post.slug === slug); + } private calculateSlug(filePath: string, frontmatter: any): string { const baseName = basename(filePath, '.md'); diff --git a/nx-dev/feature-search/src/lib/algolia-search.tsx b/nx-dev/feature-search/src/lib/algolia-search.tsx index c3bb3edea3..b82187f93f 100644 --- a/nx-dev/feature-search/src/lib/algolia-search.tsx +++ b/nx-dev/feature-search/src/lib/algolia-search.tsx @@ -6,7 +6,7 @@ import { import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'; import Head from 'next/head'; import Link from 'next/link'; -import { useRouter } from 'next/router'; +import { useRouter } from 'next/navigation'; import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; diff --git a/nx-dev/nx-dev/app/app-router-analytics.tsx b/nx-dev/nx-dev/app/app-router-analytics.tsx new file mode 100644 index 0000000000..9bc143e7f7 --- /dev/null +++ b/nx-dev/nx-dev/app/app-router-analytics.tsx @@ -0,0 +1,18 @@ +'use client'; +import { sendPageViewEvent } from '@nx/nx-dev/feature-analytics'; +import { usePathname } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +export default function AppRouterAnalytics({ gaMeasurementId }) { + const pathName = usePathname(); + const [lastPath, setLastPath] = useState(pathName); + + useEffect(() => { + if (pathName !== lastPath) { + setLastPath(pathName); + sendPageViewEvent({ gaId: gaMeasurementId, path: pathName }); + } + }, [pathName, gaMeasurementId, lastPath]); + + return <>; +} diff --git a/nx-dev/nx-dev/app/blog/[slug]/page.tsx b/nx-dev/nx-dev/app/blog/[slug]/page.tsx new file mode 100644 index 0000000000..c604b2aa23 --- /dev/null +++ b/nx-dev/nx-dev/app/blog/[slug]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata, ResolvingMetadata } from 'next'; +import { blogApi } from '../../../lib/blog.api'; +import { BlogDetails } from '@nx/nx-dev/ui-blog'; +interface BlogPostDetailProps { + params: { slug: string }; +} + +export async function generateMetadata( + { params: { slug } }: BlogPostDetailProps, + parent: ResolvingMetadata +): Promise { + const post = await blogApi.getBlogPostBySlug(slug); + const previousImages = (await parent).openGraph?.images ?? []; + return { + title: `${post.title} | Nx Blog`, + description: 'Latest news from the Nx & Nx Cloud core team', + openGraph: { + url: `https://nx.dev/blog/${slug}`, + title: post.title, + description: post.description, + images: [ + { + url: post.cover_image + ? `https://nx.dev${post.cover_image}` + : 'https://nx.dev/socials/nx-media.png', + width: 800, + height: 421, + alt: 'Nx: Smart, Fast and Extensible Build System', + type: 'image/jpeg', + }, + ...previousImages, + ], + }, + }; +} + +export async function generateStaticParams() { + return (await blogApi.getBlogs()).map((post) => { + return { slug: post.slug }; + }); +} + +export default async function BlogPostDetail({ + params: { slug }, +}: BlogPostDetailProps) { + const blog = await blogApi.getBlogPostBySlug(slug); + return blog ? ( + <> + {/* This empty div is necessary as app router does not automatically scroll on route changes */} +
+ + + ) : null; +} diff --git a/nx-dev/nx-dev/app/blog/page.tsx b/nx-dev/nx-dev/app/blog/page.tsx new file mode 100644 index 0000000000..baa652113b --- /dev/null +++ b/nx-dev/nx-dev/app/blog/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from 'next'; +import { blogApi } from '../../lib/blog.api'; +import { BlogContainer } from '@nx/nx-dev/ui-blog'; + +export const metadata: Metadata = { + title: 'Nx Blog - Updates from the Nx & Nx Cloud team', + description: 'Latest news from the Nx & Nx Cloud core team', + openGraph: { + url: 'https://nx.dev/blog', + title: 'Nx Blog - Updates from the Nx & Nx Cloud team', + description: + 'Stay updated with the latest news, articles, and updates from the Nx & Nx Cloud team.', + images: [ + { + url: 'https://nx.dev/socials/nx-media.png', + width: 800, + height: 421, + alt: 'Nx: Smart Monorepos · Fast CI', + type: 'image/jpeg', + }, + ], + siteName: 'NxDev', + type: 'website', + }, +}; +async function getBlogs() { + return await blogApi.getBlogPosts(); +} + +export default async function BlogIndex() { + const blogs = await getBlogs(); + return ; +} diff --git a/nx-dev/nx-dev/app/global-scripts.tsx b/nx-dev/nx-dev/app/global-scripts.tsx new file mode 100644 index 0000000000..9244dbe09b --- /dev/null +++ b/nx-dev/nx-dev/app/global-scripts.tsx @@ -0,0 +1,77 @@ +import Script from 'next/script'; + +export default function GlobalScripts({ gaMeasurementId }) { + return ( + <> +