From 4462fe41601279764ae512a69a0857177575cbb3 Mon Sep 17 00:00:00 2001 From: Xinyao Date: Thu, 12 Dec 2024 20:28:25 +0800 Subject: [PATCH] feat: seo --- packages/client/src/components/side-bar.tsx | 6 + .../client/src/components/ui/resizable.tsx | 2 +- .../client/src/hooks/use-search-element.tsx | 17 +- packages/client/src/pages/provider.tsx | 4 +- .../routes/(components)/current-route.tsx | 31 +- .../pages/seo/(components)/facebook-card.tsx | 43 +++ .../pages/seo/(components)/google-card.tsx | 83 +++++ .../seo/(components)/open-graph-table.tsx | 180 ++++++++++ .../src/pages/seo/(components)/x-card.tsx | 81 +++++ packages/client/src/pages/seo/page.tsx | 167 +++++++++ packages/client/src/routes.tsx | 30 +- packages/core/package.json | 1 + packages/core/src/features/seo.ts | 320 ++++++++++++++++++ .../src/toolbar/message-provider.client.tsx | 5 + packages/shared/package.json | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/metadata.ts | 18 + packages/shared/src/utils/frame-message.ts | 4 + playgrounds/app-router/public/banner.jpg | Bin 0 -> 10681 bytes playgrounds/app-router/src/app/layout.tsx | 5 - playgrounds/app-router/src/app/page.tsx | 7 + playgrounds/app-router/src/app/seo/page.tsx | 73 ++++ playgrounds/app-router/src/app/seo/x/page.tsx | 88 +++++ pnpm-lock.yaml | 38 ++- pnpm-workspace.yaml | 1 + 25 files changed, 1157 insertions(+), 49 deletions(-) create mode 100644 packages/client/src/pages/seo/(components)/facebook-card.tsx create mode 100644 packages/client/src/pages/seo/(components)/google-card.tsx create mode 100644 packages/client/src/pages/seo/(components)/open-graph-table.tsx create mode 100644 packages/client/src/pages/seo/(components)/x-card.tsx create mode 100644 packages/client/src/pages/seo/page.tsx create mode 100644 packages/core/src/features/seo.ts create mode 100644 packages/shared/src/types/metadata.ts create mode 100644 playgrounds/app-router/public/banner.jpg create mode 100644 playgrounds/app-router/src/app/seo/page.tsx create mode 100644 playgrounds/app-router/src/app/seo/x/page.tsx diff --git a/packages/client/src/components/side-bar.tsx b/packages/client/src/components/side-bar.tsx index 8b73ea2..b2dd4a7 100644 --- a/packages/client/src/components/side-bar.tsx +++ b/packages/client/src/components/side-bar.tsx @@ -57,6 +57,12 @@ const menuItems = [ link: '/network', icon: 'i-ri-earth-line', }, + { + value: 'seo', + label: 'SEO', + link: '/seo', + icon: 'i-ri-seo-line', + }, { value: 'terminal', label: 'Terminal', diff --git a/packages/client/src/components/ui/resizable.tsx b/packages/client/src/components/ui/resizable.tsx index b6ea756..c06ba4e 100644 --- a/packages/client/src/components/ui/resizable.tsx +++ b/packages/client/src/components/ui/resizable.tsx @@ -28,7 +28,7 @@ const ResizableHandle = ({ {...props} > {withHandle ? ( -
+
) : null} diff --git a/packages/client/src/hooks/use-search-element.tsx b/packages/client/src/hooks/use-search-element.tsx index 5703038..ae171e1 100644 --- a/packages/client/src/hooks/use-search-element.tsx +++ b/packages/client/src/hooks/use-search-element.tsx @@ -35,12 +35,17 @@ export default function useSearchElement( const element = useMemo(() => { return (
- } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - /> +
+ setSearchText(e.target.value)} + /> +
+ +
+
{searchText ? {filteredData.length} matched · : null} diff --git a/packages/client/src/pages/provider.tsx b/packages/client/src/pages/provider.tsx index 09ab5c5..0088473 100644 --- a/packages/client/src/pages/provider.tsx +++ b/packages/client/src/pages/provider.tsx @@ -66,8 +66,8 @@ function Main({ children }: Props) { )} > -
-
+
+
{children}
diff --git a/packages/client/src/pages/routes/(components)/current-route.tsx b/packages/client/src/pages/routes/(components)/current-route.tsx index 0949fe3..0a38cb0 100644 --- a/packages/client/src/pages/routes/(components)/current-route.tsx +++ b/packages/client/src/pages/routes/(components)/current-route.tsx @@ -3,8 +3,14 @@ import React from 'react' import { useQuery } from '@tanstack/react-query' import { Input } from '@/components/ui/input' import { getQueryClient, useMessageClient } from '@/lib/client' +import { cn } from '@/lib/utils' -export default function CurrentRoute() { +interface CurrentRouteProps { + className?: string + actions?: React.ReactNode +} + +export default function CurrentRoute({ className, actions }: CurrentRouteProps) { const messageClient = useMessageClient() const { data } = useQuery({ queryKey: ['getRoute'], @@ -25,7 +31,7 @@ export default function CurrentRoute() { }, [data]) return ( -
+
{data === currentRoute ? ( Current Route @@ -35,15 +41,18 @@ export default function CurrentRoute() {
)}
- setCurrentRoute(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleNavigate(currentRoute) - } - }} - /> +
+ setCurrentRoute(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleNavigate(currentRoute) + } + }} + /> + {actions} +
Edit path above to navigate
) diff --git a/packages/client/src/pages/seo/(components)/facebook-card.tsx b/packages/client/src/pages/seo/(components)/facebook-card.tsx new file mode 100644 index 0000000..6d5a21f --- /dev/null +++ b/packages/client/src/pages/seo/(components)/facebook-card.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import type { SEOMetadata } from '@next-devtools/shared/types' + +interface FacebookCardProps { + data?: SEOMetadata +} + +const FacebookCard = ({ data }: FacebookCardProps) => { + if (!data || !data.openGraph) + return ( +
+

Missing `og:` related metadata

+ + Learn more + +
+ ) + + const og = data?.openGraph + const title = (og?.title as string) || (data?.title as string) || data.name + const description = og?.description || data?.description + const image = (og?.images as any[])?.[0] + const siteName = og?.siteName || data?.applicationName || '' + + return ( +
+ {image ? ( +
+ +
+ ) : null} +
+
{siteName}
+
{title}
+
+ {description} +
+
+
+ ) +} + +export default FacebookCard diff --git a/packages/client/src/pages/seo/(components)/google-card.tsx b/packages/client/src/pages/seo/(components)/google-card.tsx new file mode 100644 index 0000000..273f9ad --- /dev/null +++ b/packages/client/src/pages/seo/(components)/google-card.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { formatDistanceToNow } from 'date-fns' +import type { SEOMetadata } from '@next-devtools/shared/types' + +interface GoogleCardProps { + data?: SEOMetadata +} + +const GoogleCard = ({ data }: GoogleCardProps) => { + if (!data) return null + + const icon = (data.icons as any)?.icon + const name = data.name + const title = data.title as string + const description = data.description + const url = typeof window !== 'undefined' ? window.location.origin : '' + const updatedAt = React.useMemo(() => { + // JSON-LD + if (data.jsonLd) { + const jsonLd = Array.isArray(data.jsonLd) ? data.jsonLd[0] : data.jsonLd + if ('datePublished' in jsonLd) { + return new Date(jsonLd.datePublished as string) + } + if ('dateModified' in jsonLd) { + return new Date(jsonLd.dateModified as string) + } + } + + // OpenGraph + if ((data.openGraph as any)?.published_time) { + return new Date((data.openGraph as any).published_time) + } else if ((data.openGraph as any)?.modifiedTime) { + return new Date((data.openGraph as any).modifiedTime) + } + + return null + }, [data]) + + return ( +
+
+
+
+ {icon ? ( + + ) : ( + + + + )} +
+ +
+
{name}
+
{url}
+
+
+ +
+

+ {title} +

+
+ {updatedAt ? ( + + {formatDistanceToNow(updatedAt)} + {' — '} + + ) : null} + {description} +
+
+
+
+ ) +} + +export default GoogleCard diff --git a/packages/client/src/pages/seo/(components)/open-graph-table.tsx b/packages/client/src/pages/seo/(components)/open-graph-table.tsx new file mode 100644 index 0000000..ee1b4e6 --- /dev/null +++ b/packages/client/src/pages/seo/(components)/open-graph-table.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import type { SEOMetadata } from '@next-devtools/shared/types' + +// from https://github.com/nuxt/devtools +export const ogTags = [ + { + name: 'title', + docs: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title', + description: 'A concise and descriptive title for the browser that accurately summarizes the content of the page.', + }, + { + name: 'description', + description: + 'A one to two sentence summary for search engines that includes relevant keywords to improve visibility in search results.', + }, + { + name: 'icon', + description: + 'A small image that appears in the browser tab and bookmark menu to help users easily identify the page.', + }, + { + name: 'lang', + description: 'The primary language of the page to help search engines and browsers understand the content.', + }, + { + name: 'og:title', + docs: 'https://ogp.me/#metadata', + description: 'A title for the link preview used by social media platforms.', + }, + { + name: 'og:description', + docs: 'https://ogp.me/#metadata', + description: 'A description for the link preview used by social media platforms.', + }, + { + name: 'og:image', + docs: 'https://ogp.me/#metadata', + description: 'An image for the link preview used by social media platforms.', + }, + { + name: 'og:url', + docs: 'https://ogp.me/#metadata', + description: + 'A canonical URL for the link preview used to specify the preferred URL to display in search engine results and social media previews when multiple URLs may point to the same page.', + }, + { + name: 'twitter:title', + docs: 'https://developer.x.com/en/docs/x-for-websites/cards/overview/abouts-cards', + description: 'A title for the Twitter card used to provide a preview of the content shared on the page.', + }, + { + name: 'twitter:description', + docs: 'https://developer.x.com/en/docs/x-for-websites/cards/overview/abouts-cards', + description: 'A description for the Twitter card used to provide a preview of the content shared on the page.', + }, + { + name: 'twitter:image', + docs: 'https://developer.x.com/en/docs/x-for-websites/cards/overview/abouts-cards', + description: 'An image for the Twitter card used to provide a preview of the content shared on the page.', + }, + { + name: 'twitter:card', + docs: 'https://developer.x.com/en/docs/x-for-websites/cards/overview/abouts-cards', + description: + 'The type of Twitter card to use, which determines the type of card to display in link previews on Twitter.', + }, +] +interface Row { + id: string + content: (data: SEOMetadata) => React.ReactNode +} +const rows: Row[] = [ + // HTML Meta Tags + { + id: 'title', + content: (data) => data.title as string, + }, + { + id: 'description', + content: (data) => data.description as string, + }, + // og + { + id: 'og:url', + content: (data) => data.openGraph?.url, + }, + { + id: 'og:title', + content: (data) => data.openGraph?.title as string, + }, + { + id: 'og:description', + content: (data) => data.openGraph?.description as string, + }, + { + id: 'og:image', + content: (data) => (data.openGraph?.images as any[])?.[0]?.url, + }, + // twitter + { + id: 'twitter:card', + content: (data) => (data.twitter as any)?.card, + }, + { + id: 'twitter:title', + content: (data) => data.twitter?.title, + }, + { + id: 'twitter:description', + content: (data) => data.twitter?.description, + }, + { + id: 'twitter:image', + content: (data) => (data.twitter as any)?.images?.[0]?.url, + }, +] + +interface OpenGraphTableProps { + data?: SEOMetadata +} + +const OpenGraphTable = ({ data }: OpenGraphTableProps) => { + return ( +
+ {rows.map((row, index) => { + const content = data ? row.content(data) : null + const missing = data?.missing.includes(row.id) + const warning = data?.warnings.includes(row.id) + const description = ogTags.find((item) => item.name === row.id)?.description + const docs = ogTags.find((item) => item.name === row.id)?.docs + + return ( +
+
+ {missing ? : null} + {warning ? : null} + {row.id} +
+ +
+
{content}
+ {missing || warning ? ( +
+ {description ? {description} : null} + {docs ? ( + + ) : null} +
+ ) : null} +
+
+ ) + })} +
+ ) +} + +export default OpenGraphTable diff --git a/packages/client/src/pages/seo/(components)/x-card.tsx b/packages/client/src/pages/seo/(components)/x-card.tsx new file mode 100644 index 0000000..2d18068 --- /dev/null +++ b/packages/client/src/pages/seo/(components)/x-card.tsx @@ -0,0 +1,81 @@ +// Learn more about the [X Card markup reference](https://developer.x.com/en/docs/x-for-websites/cards/overview/markup). +import React from 'react' +import { cn } from '@/lib/utils' +import type { SEOMetadata } from '@next-devtools/shared/types' + +interface XCardProps { + data?: SEOMetadata +} + +const XCard = ({ data }: XCardProps) => { + if (!data || !data.twitter) + return ( +
+

Missing `twitter:` related metadata

+ + Learn more + +
+ ) + + const title = data.title as string + const image = (data.twitter?.images as any[])?.[0] + const description = data.twitter?.description + + const type = React.useMemo(() => { + if (!image) return 'summary' + return (data.twitter as any)?.card || 'summary_large_image' + }, [image, data.twitter]) + + return ( +
+
+
+ {image ? ( + type === 'summary_large_image' ? ( + {image?.alt} + ) : ( + {image?.alt} + ) + ) : ( +
+ +
+ )} +
+
+
+ {window.location.origin} +
+
{title}
+
+ {description} +
+
+
+
+ ) +} + +export default XCard diff --git a/packages/client/src/pages/seo/page.tsx b/packages/client/src/pages/seo/page.tsx new file mode 100644 index 0000000..ce18c93 --- /dev/null +++ b/packages/client/src/pages/seo/page.tsx @@ -0,0 +1,167 @@ +'use client' + +import React from 'react' +import { useQuery } from '@tanstack/react-query' +import { useLocalStorage } from 'react-use' +import { Skeleton } from '@/components/ui/skeleton' +import { useMessageClient } from '@/lib/client' +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import CurrentRoute from '../routes/(components)/current-route' +import XCard from './(components)/x-card' +import GoogleCard from './(components)/google-card' +import FacebookCard from './(components)/facebook-card' +import OpenGraphTable from './(components)/open-graph-table' + +const PLATFORMS = ['google', 'x', 'facebook'] as const + +export default function Page() { + const [direction, setDirection] = useLocalStorage<'horizontal' | 'vertical'>( + 'NEXT_DEVTOOLS_SEO_LAYOUT_DIRECTION', + 'horizontal', + ) + const [platform, setPlatform] = useLocalStorage<(typeof PLATFORMS)[number]>( + 'NEXT_DEVTOOLS_SEO_PLATFORM', + PLATFORMS[1], + ) + const messageClient = useMessageClient() + const { data: route } = useQuery({ + queryKey: ['getRoute'], + queryFn: () => messageClient.getRoute(), + }) + const { + data: seoMetadata, + isLoading: isLoadingSEOMetadata, + refetch: refetchSEOMetadata, + } = useQuery({ + queryKey: ['getSEOMetadata', route], + queryFn: () => messageClient.getSEOMetadata(route), + }) + + return ( + + +
+ + + + + } + /> + {isLoadingSEOMetadata ? ( +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ ) : ( +
+
+

+ Validate how your Open Graph data is used for link previews on social platforms. +

+ + +
+
+ )} +
+
+ + + + +
+ setPlatform(v as (typeof PLATFORMS)[number])}> +
+ + {PLATFORMS.map((platform) => ( + + {platform} + + ))} + +
+ +
+ {PLATFORMS.map((platform) => { + if (platform === 'google') { + return ( + +
+ +
+
+ ) + } else if (platform === 'x') { + return ( + +
+ +
+
+ ) + } else if (platform === 'facebook') { + return ( + +
+ +
+
+ ) + } + return null + })} +
+
+
+
+
+ ) +} diff --git a/packages/client/src/routes.tsx b/packages/client/src/routes.tsx index 47fedcb..48155e6 100644 --- a/packages/client/src/routes.tsx +++ b/packages/client/src/routes.tsx @@ -1,18 +1,19 @@ import { RouterProvider, createBrowserRouter } from 'react-router' import { CLIENT_BASE_PATH } from '@next-devtools/shared/constants' +import Provider from './pages/provider' +import Error from './pages/error' import HomePage from './pages/page' -import OverviewPage from './pages/overview/page' -import RoutesPage from './pages/routes/page' -import ComponentsPage from './pages/components/page' import AssetsPage from './pages/assets/page' -import PackagesPage from './pages/packages/page' +import BundleAnalyzerPage from './pages/bundle-analyzer/page' +import ComponentsPage from './pages/components/page' import EnvsPage from './pages/envs/page' import NetworkPage from './pages/network/page' -import TerminalPage from './pages/terminal/page' -import BundleAnalyzerPage from './pages/bundle-analyzer/page' +import OverviewPage from './pages/overview/page' +import PackagesPage from './pages/packages/page' +import RoutesPage from './pages/routes/page' +import SEOPage from './pages/seo/page' import SettingsPage from './pages/settings/page' -import Provider from './pages/provider' -import Error from './pages/error' +import TerminalPage from './pages/terminal/page' export const routes = createBrowserRouter( [ @@ -22,16 +23,17 @@ export const routes = createBrowserRouter( ErrorBoundary: Error, children: [ { index: true, Component: HomePage }, - { path: 'overview', Component: OverviewPage }, - { path: 'routes', Component: RoutesPage }, - { path: 'components', Component: ComponentsPage }, { path: 'assets', Component: AssetsPage }, - { path: 'packages', Component: PackagesPage }, + { path: 'bundle-analyzer', Component: BundleAnalyzerPage }, + { path: 'components', Component: ComponentsPage }, { path: 'envs', Component: EnvsPage }, { path: 'network', Component: NetworkPage }, - { path: 'terminal', Component: TerminalPage }, - { path: 'bundle-analyzer', Component: BundleAnalyzerPage }, + { path: 'overview', Component: OverviewPage }, + { path: 'packages', Component: PackagesPage }, + { path: 'routes', Component: RoutesPage }, + { path: 'seo', Component: SEOPage }, { path: 'settings', Component: SettingsPage }, + { path: 'terminal', Component: TerminalPage }, ], }, ], diff --git a/packages/core/package.json b/packages/core/package.json index ee55f42..16bb608 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,6 +80,7 @@ "nypm": "catalog:", "react-docgen": "catalog:", "react-use": "catalog:", + "schema-dts": "catalog:", "semver": "catalog:", "sirv": "catalog:", "superjson": "catalog:", diff --git a/packages/core/src/features/seo.ts b/packages/core/src/features/seo.ts new file mode 100644 index 0000000..c9fb1df --- /dev/null +++ b/packages/core/src/features/seo.ts @@ -0,0 +1,320 @@ +// browser-only + +import type { WithContext } from 'schema-dts' +import type { SEOMetadata } from '@next-devtools/shared/types' +import type { Facebook } from 'next/dist/lib/metadata/types/extra-types' + +export function getSEOMetadata(route?: string): Promise { + return new Promise((resolve) => { + if (route && !window.location.pathname.includes(route)) { + return setTimeout(() => resolve(getSEOMetadata(route)), 1000) + } + + const result: SEOMetadata = { + missing: [], + warnings: [], + } + + // Basic metadata + function getBasicMetadata() { + // title + const title = document.title + if (title) result.title = title + else result.missing.push('title') + + // description + const description = document.querySelector('meta[name="description"]')?.getAttribute('content') + if (description) result.description = description + else result.missing.push('description') + + // keywords + const keywords = document.querySelector('meta[name="keywords"]')?.getAttribute('content') + if (keywords) result.keywords = keywords.split(',').map((k) => k.trim()) + + // author + const author = document.querySelector('meta[name="author"]')?.getAttribute('content') + const authorLink = document.querySelector('link[rel="author"]')?.getAttribute('href') + if (author || authorLink) { + result.authors = [ + { + name: author || '', + url: authorLink || '', + }, + ] + } + } + + // Open Graph - https://ogp.me/ + function getOpenGraphData() { + const og: Record = {} + + // images is a array of objects - + const images: Record[] = [] + const ogTags = document.querySelectorAll('[property^="og:"]') + ogTags.forEach((tag) => { + const property = tag.getAttribute('property')?.replace('og:', '') + const content = tag.getAttribute('content') + if (property && content) { + if (property.startsWith('image')) { + const [, prop] = property.split(':') + const image: Record = prop ? images.at(-1) || { url: content } : { url: content } + if (prop) { + image[prop] = content + } + if (!prop) { + images.push(image) + } + } else { + og[property] = content + } + } + }) + if (images.length > 0) { + og.images = images + } + + // music + const musicTags = document.querySelectorAll('[property^="music:"]') + if (musicTags.length > 0) { + musicTags.forEach((tag) => { + const property = tag.getAttribute('property')?.replace('music:', '') + const content = tag.getAttribute('content') + if (property) og[property] = content + }) + } + + // video + const videoTags = document.querySelectorAll('[property^="video:"]') + if (videoTags.length > 0) { + videoTags.forEach((tag) => { + const property = tag.getAttribute('property')?.replace('video:', '') + const content = tag.getAttribute('content') + if (property) og[property] = content + }) + } + + // article + const articleTags = document.querySelectorAll('[property^="article:"]') + if (articleTags.length > 0) { + articleTags.forEach((tag) => { + const property = tag.getAttribute('property')?.replace('article:', '') + const content = tag.getAttribute('content') + if (property) og[property] = content + }) + } + + // book + const bookTags = document.querySelectorAll('[property^="book:"]') + if (bookTags.length > 0) { + bookTags.forEach((tag) => { + const property = tag.getAttribute('property')?.replace('book:', '') + const content = tag.getAttribute('content') + if (property) og[property] = content + }) + } + + // profile + const profileTags = document.querySelectorAll('[property^="profile:"]') + if (profileTags.length > 0) { + profileTags.forEach((tag) => { + const property = tag.getAttribute('property')?.replace('profile:', '') + const content = tag.getAttribute('content') + if (property) og[property] = content + }) + } + + if (Object.keys(og).length > 0) { + result.openGraph = og + } + } + + // Twitter Card + function getTwitterData() { + const twitter: Record = {} + const twitterTags = document.querySelectorAll('[name^="twitter:"]') + const images: Record[] = [] + + twitterTags.forEach((tag) => { + const name = tag.getAttribute('name')?.replace('twitter:', '') + const content = tag.getAttribute('content') + if (name && content) { + if (name.startsWith('image')) { + const [, prop] = name.split(':') + const image: Record = prop ? images.at(-1) || { url: content } : { url: content } + if (prop) { + image[prop] = content + } + if (!prop) { + images.push(image) + } + } else { + twitter[name] = content + } + } + }) + + if (images.length > 0) { + twitter.images = images + } + if (Object.keys(twitter).length > 0) { + result.twitter = twitter + } + } + + // Icon data + function getIconsData() { + const icons: Record = {} + const iconLinks = document.querySelectorAll('link[rel*="icon"]') + + iconLinks.forEach((link) => { + const rel = link.getAttribute('rel') + const href = link.getAttribute('href') + if (rel && href) { + if (rel === 'icon') { + icons.icon = href + } else if (rel === 'apple-touch-icon') { + icons.apple = href + } + } + }) + + if (Object.keys(icons).length > 0) { + result.icons = icons + } + } + + // Robots + function getRobotsData() { + const robots = document.querySelector('meta[name="robots"]')?.getAttribute('content') + if (robots) { + const robotsObj: Record = {} + robots.split(',').forEach((directive) => { + const trimmed = directive.trim() + if (trimmed.startsWith('no')) { + robotsObj[trimmed.replace('no', '')] = false + } else { + robotsObj[trimmed] = true + } + }) + result.robots = robotsObj + } + } + + // Verification + function getVerificationData() { + const verification: Record = {} + const verificationTags = document.querySelectorAll('meta[name$="-verification"]') + + verificationTags.forEach((tag) => { + const name = tag.getAttribute('name')?.replace('-verification', '') + const content = tag.getAttribute('content') + if (name && content) { + verification[name] = content + } + }) + + if (Object.keys(verification).length > 0) { + result.verification = verification + } + } + + // Facebook + function getFacebookData() { + const fbAppId = document.querySelector('meta[property="fb:app_id"]')?.getAttribute('content') + const fbAdmins = document.querySelector('meta[property="fb:admins"]')?.getAttribute('content') + + if (fbAppId || fbAdmins) { + result.facebook = {} as Facebook + if (fbAppId) result.facebook.appId = fbAppId + if (fbAdmins) result.facebook.admins = [fbAdmins] + } + } + + // JSON-LD + function getJSONLDData() { + const scripts = document.querySelectorAll('script[type="application/ld+json"]') + + scripts.forEach((script) => { + const jsonRaw = (script.textContent as string) ?? '{}' + const json = JSON.parse(jsonRaw) as WithContext + if (!result.jsonLd) result.jsonLd = [] + result.jsonLd.push(json) + }) + } + + /** + * name + * Determine the name from multiple sources, including: + * - WebSite structured data - https://json-ld.org/ + * - + * - <h1> + * - og:size_name + */ + function getName() { + const jsonLd = result.jsonLd?.find((json) => json.name) + if (jsonLd?.name) { + result.name = jsonLd.name + return + } + const titleTag = document.querySelector('title')?.textContent + if (titleTag) { + result.name = titleTag + return + } + const h1Tag = document.querySelector('h1')?.textContent + if (h1Tag) { + result.name = h1Tag + return + } + const ogSiteName = document.querySelector('meta[property="og:site_name"]')?.getAttribute('content') + if (ogSiteName) { + result.name = ogSiteName + } + } + + // Run all checks + getBasicMetadata() + getOpenGraphData() + getTwitterData() + getIconsData() + getRobotsData() + getVerificationData() + getFacebookData() + getJSONLDData() + getName() + + // Check the necessary OpenGraph data + if (result.openGraph) { + if (!('url' in result.openGraph)) { + result.warnings.push('og:url') + } + if (!('title' in result.openGraph)) { + result.warnings.push('og:title') + } + if (!('description' in result.openGraph)) { + result.warnings.push('og:description') + } + if (!('images' in result.openGraph)) { + result.warnings.push('og:image') + } + ;(result.openGraph.images as any[])?.forEach((image) => { + if (!('url' in image)) { + result.warnings.push('og:image') + } + }) + } else { + result.warnings.push(...['og:url', 'og:title', 'og:description', 'og:image']) + } + + // Check the necessary Twitter Card data + if (result.twitter) { + if (!('card' in result.twitter)) { + result.missing.push('twitter:card') + } + } else { + result.warnings.push(...['twitter:card', 'twitter:title', 'twitter:description', 'twitter:image']) + } + + return resolve(result) + }) +} diff --git a/packages/core/src/toolbar/message-provider.client.tsx b/packages/core/src/toolbar/message-provider.client.tsx index 6020617..1717e0b 100644 --- a/packages/core/src/toolbar/message-provider.client.tsx +++ b/packages/core/src/toolbar/message-provider.client.tsx @@ -3,6 +3,7 @@ import React from 'react' import { usePathname, useRouter } from 'next/navigation' import { createFrameMessageHandler } from '@next-devtools/shared/utils' +import { getSEOMetadata } from '../features/seo' import type { NavigateOptions } from 'next/dist/shared/lib/app-router-context.shared-runtime' import type { FrameMessageHandler } from '@next-devtools/shared/utils' @@ -18,6 +19,7 @@ export function MessageProvider({ children, iframeRef }: Props) { React.useEffect(() => { const handler: FrameMessageHandler = { + // routes getRoute: async () => latestPathname.current, pushRoute: async (href: string, options?: NavigateOptions) => { router.push(href, options) @@ -25,6 +27,9 @@ export function MessageProvider({ children, iframeRef }: Props) { backRoute: async () => { router.back() }, + + // seo + getSEOMetadata, } const unsubscribe = createFrameMessageHandler(handler, iframeRef) diff --git a/packages/shared/package.json b/packages/shared/package.json index 95e6d07..a9f0a32 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -82,6 +82,7 @@ "devDependencies": { "@types/react": "catalog:react19", "next": "catalog:next15", + "schema-dts": "catalog:", "webpack": "catalog:", "zustand": "catalog:" } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 429286e..1238ed5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,5 +1,6 @@ export * from './features' export * from './internal' +export * from './metadata' export * from './network' export * from './rpc' export * from './settings' diff --git a/packages/shared/src/types/metadata.ts b/packages/shared/src/types/metadata.ts new file mode 100644 index 0000000..d454f3b --- /dev/null +++ b/packages/shared/src/types/metadata.ts @@ -0,0 +1,18 @@ +import type { Metadata } from 'next/dist/types' +import type { WithContext } from 'schema-dts' + +// Simplified from the type of nextjs metadata +export interface SEOMetadata extends Metadata { + missing: string[] + warnings: string[] + jsonLd?: WithContext<any>[] + + /** + * Determine the name from multiple sources, including: + * - WebSite structured data - https://json-ld.org/ + * - <title> + * - <h1> + * - og:size_name + */ + name?: string +} diff --git a/packages/shared/src/utils/frame-message.ts b/packages/shared/src/utils/frame-message.ts index d957173..aac6f4b 100644 --- a/packages/shared/src/utils/frame-message.ts +++ b/packages/shared/src/utils/frame-message.ts @@ -1,3 +1,5 @@ +import type { SEOMetadata } from '../types' + const CLIENT_SOURCE = 'next-devtools-client' const FRAME_SOURCE = 'next-devtools-frame' @@ -53,4 +55,6 @@ export interface FrameMessageHandler extends FrameMessageFunctions { getRoute: () => Promise<string> pushRoute: (href: string) => Promise<void> backRoute: (href: string) => Promise<void> + // seo + getSEOMetadata: (route?: string) => Promise<SEOMetadata> } diff --git a/playgrounds/app-router/public/banner.jpg b/playgrounds/app-router/public/banner.jpg new file mode 100644 index 0000000000000000000000000000000000000000..19ddbc3133948628d19fe87c7f0adcf894135b30 GIT binary patch literal 10681 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 literal 0 HcmV?d00001 diff --git a/playgrounds/app-router/src/app/layout.tsx b/playgrounds/app-router/src/app/layout.tsx index 35ecb0c..826433f 100644 --- a/playgrounds/app-router/src/app/layout.tsx +++ b/playgrounds/app-router/src/app/layout.tsx @@ -14,11 +14,6 @@ const geistMono = localFont({ weight: '100 900', }) -export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} - export default function RootLayout({ children, }: Readonly<{ diff --git a/playgrounds/app-router/src/app/page.tsx b/playgrounds/app-router/src/app/page.tsx index a05aa06..da7627a 100644 --- a/playgrounds/app-router/src/app/page.tsx +++ b/playgrounds/app-router/src/app/page.tsx @@ -39,6 +39,13 @@ export default function Home() { > Pokemon </Link> + <Link + className="flex h-10 items-center justify-center rounded-full border border-solid border-black/[.08] px-4 text-sm transition-colors hover:border-transparent hover:bg-[#f2f2f2] sm:h-12 sm:min-w-44 sm:px-5 sm:text-base dark:border-white/[.145] dark:hover:bg-[#1a1a1a]" + href="/seo" + rel="noopener noreferrer" + > + SEO + </Link> </div> </main> <footer className="row-start-3 flex flex-wrap items-center justify-center gap-6"> diff --git a/playgrounds/app-router/src/app/seo/page.tsx b/playgrounds/app-router/src/app/seo/page.tsx new file mode 100644 index 0000000..d1270d5 --- /dev/null +++ b/playgrounds/app-router/src/app/seo/page.tsx @@ -0,0 +1,73 @@ +import type { Metadata } from 'next' + +const SITE_NAME = 'Chicken Garden Paradise' +const SITE_DESC = + 'Discover the joy of backyard chicken farming and organic gardening. Learn how to create your perfect garden sanctuary.' +const BASE_URL = 'https://www.chickengarden.paradise' +const DEFAULT_IMAGE = `/banner.jpg` + +export async function generateMetadata(): Promise<Metadata> { + return { + title: SITE_NAME, + description: SITE_DESC, + keywords: ['chicken farming', 'organic gardening', 'sustainable living', 'backyard gardens'], + authors: [{ name: 'Garden Paradise LLC' }], + themeColor: '#4ade80', + + // Open Graph + openGraph: { + type: 'website', + url: BASE_URL, + title: SITE_NAME, + description: SITE_DESC, + siteName: SITE_NAME, + images: [ + { + url: DEFAULT_IMAGE, + width: 1200, + height: 630, + alt: 'Chicken Garden Paradise Banner', + }, + 'https://picsum.photos/300/200', + ], + locale: 'en_US', + }, + + // Twitter + twitter: { + card: 'summary', + title: SITE_NAME, + description: SITE_DESC, + // images: [DEFAULT_IMAGE], + creator: '@ChickenGarden', + }, + + // other + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + 'max-video-preview': -1, + 'max-image-preview': 'large', + 'max-snippet': -1, + }, + }, + viewport: { + width: 'device-width', + initialScale: 1, + }, + icons: { + icon: '/favicon.ico', + apple: '/icon.png', + }, + alternates: { + canonical: BASE_URL, + }, + } +} + +export default function Page() { + return <h1>SEO</h1> +} diff --git a/playgrounds/app-router/src/app/seo/x/page.tsx b/playgrounds/app-router/src/app/seo/x/page.tsx new file mode 100644 index 0000000..e015efe --- /dev/null +++ b/playgrounds/app-router/src/app/seo/x/page.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from 'next' + +const TITLE = 'Next.js 15.1 | Next.js' +const DESCRIPTION = + 'Next.js 15.1 introduces React 19 stable support, improved error debugging, new experimental authorization APIs, and more.' +const BASE_URL = 'https://nextjs.org' +const IMAGE_URL = 'https://nextjs.org/static/blog/next-15-1/twitter-card.png' + +export function generateMetadata(): Metadata { + return { + title: TITLE, + description: DESCRIPTION, + + // Basic metadata + authors: [{ name: 'Delba de Oliveira' }, { name: 'Jimmy Lai' }, { name: 'Rich Haines' }], + + // OpenGraph + openGraph: { + title: TITLE, + siteName: 'OG siteName', + description: DESCRIPTION, + url: `${BASE_URL}/blog/next-15-1`, + images: [ + { + url: IMAGE_URL, + }, + ], + type: 'article', + publishedTime: '2024-12-10T20:00:00.000Z', + }, + + // Twitter + twitter: { + card: 'summary_large_image', + title: TITLE, + description: DESCRIPTION, + images: [ + { + url: IMAGE_URL, + alt: 'Next.js 15', + }, + { + url: IMAGE_URL, + alt: 'Next.js 15 - 2', + }, + ], + }, + + // facebook + facebook: { + appId: '12345678', + }, + + // Other + alternates: { + canonical: `${BASE_URL}/blog/next-15`, + }, + icons: { + icon: 'https://nextjs.org/favicon.ico', + }, + } +} + +export default function Page() { + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'Product', + name: TITLE, + image: IMAGE_URL, + description: DESCRIPTION, + } + const jsonLd2 = { + '@context': 'https://schema.org', + '@type': 'Product', + name: TITLE + 1111, + image: IMAGE_URL, + description: DESCRIPTION, + } + + return ( + <div> + <h1>h1Tag</h1> + Next.js 15 + <script dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} type="application/ld+json" /> + <script dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd2) }} type="application/ld+json" /> + </div> + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae52a91..362a0c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ catalogs: release-it: specifier: ^17.10.0 version: 17.10.0 + schema-dts: + specifier: ^1.1.2 + version: 1.1.2 semver: specifier: ^7.6.3 version: 7.6.3 @@ -401,10 +404,10 @@ importers: version: 14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nextra: specifier: latest - version: 3.2.4(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2) + version: 3.2.5(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2) nextra-theme-docs: specifier: latest - version: 3.2.4(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.2.4(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.2.5(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.2.5(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: catalog:react18 version: 18.3.1 @@ -674,6 +677,9 @@ importers: react-use: specifier: 'catalog:' version: 17.5.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + schema-dts: + specifier: 'catalog:' + version: 1.1.2(typescript@5.7.2) semver: specifier: 'catalog:' version: 7.6.3 @@ -739,6 +745,9 @@ importers: next: specifier: catalog:next15 version: 15.0.4(@babel/core@7.23.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + schema-dts: + specifier: 'catalog:' + version: 1.1.2(typescript@5.7.2) webpack: specifier: 'catalog:' version: 5.97.1(@swc/core@1.9.3(@swc/helpers@0.5.13))(esbuild@0.23.1) @@ -6207,16 +6216,16 @@ packages: sass: optional: true - nextra-theme-docs@3.2.4: - resolution: {integrity: sha512-3fg7zMHInuvSDURRJjh6UrbdqkK8uLs8RNriY38kVukWLvaVP2f6mmVJKIYqxVv6qAKWEzDLTr4dlJCY81eXuQ==} + nextra-theme-docs@3.2.5: + resolution: {integrity: sha512-eF0j1VNNS1rFjZOfYqlrXISaCU3+MhZ9hhXY+TUydRlfELrFWpGzrpW6MiL7hq4JvUR7OBtHHs8+A+8AYcETBQ==} peerDependencies: next: '>=13' - nextra: 3.2.4 + nextra: 3.2.5 react: '>=18' react-dom: '>=18' - nextra@3.2.4: - resolution: {integrity: sha512-xvQuPVtRoJTz4ynIbEkxYkEtviIX699lt4coij2IMmafYrBNaD0Ofj93jIz7VngYxyT9f4gWSiwqNgoIlnbsjQ==} + nextra@3.2.5: + resolution: {integrity: sha512-n665DRpI/brjHXM83G5LdlbYA2nOtjaLcWJs7mZS3gkuRDmEXpJj4XJ860xrhkYZW2iYoUMu32zzhPuFByU7VA==} engines: {node: '>=18'} peerDependencies: next: '>=13' @@ -7175,6 +7184,11 @@ packages: scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + schema-dts@1.1.2: + resolution: {integrity: sha512-MpNwH0dZJHinVxk9bT8XUdjKTxMYrA5bLtrrGmFA6PTLwlOKnhi67XoRd6/ty+Djt6ZC0slR57qFhZDNMI6DhQ==} + peerDependencies: + typescript: '>=4.1.0' + schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} @@ -14428,7 +14442,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@3.2.4(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.2.4(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + nextra-theme-docs@3.2.5(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.2.5(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@headlessui/react': 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 @@ -14436,13 +14450,13 @@ snapshots: flexsearch: 0.7.43 next: 14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: 0.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nextra: 3.2.4(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2) + nextra: 3.2.5(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) scroll-into-view-if-needed: 3.1.0 zod: 3.22.4 - nextra@3.2.4(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2): + nextra@3.2.5(@types/react@18.3.14)(acorn@8.14.0)(next@14.2.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2): dependencies: '@formatjs/intl-localematcher': 0.5.7 '@headlessui/react': 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -15641,6 +15655,10 @@ snapshots: scheduler@0.25.0: {} + schema-dts@1.1.2(typescript@5.7.2): + dependencies: + typescript: 5.7.2 + schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dcec990..42a27a8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -78,6 +78,7 @@ catalog: 'react-router': ^7.0.2 'react-use': ^17.5.1 'release-it': ^17.10.0 + 'schema-dts': ^1.1.2 'semver': ^7.6.3 'sirv': ^3.0.0 'sonner': ^1.7.0