From 5229e518808795c54d910e028432c4ef3de4a444 Mon Sep 17 00:00:00 2001 From: Sunny Date: Mon, 29 Jun 2026 15:12:09 -0400 Subject: [PATCH 1/6] feat(seo): add per page meta tags, canonical URLs, and JSON-LD Wrap the app in HelmetProvider and add a reusable Seo component that renders title, description, Open Graph, Twitter card, and canonical tags from shared site constants. The layout emits baseline tags plus site wide WebApplication and WebSite JSON-LD, and each page overrides title, description, and canonical. Courses and FAQ add BreadcrumbList JSON-LD, and the not found page is marked noindex. Prerendering is deferred; vite-react-ssg is the planned future approach over react-snap due to MUI emotion. --- package-lock.json | 36 ++++++++++++++++++ package.json | 1 + src/components/layout/Layout.tsx | 22 +++++++++++ src/components/seo/Seo.tsx | 63 ++++++++++++++++++++++++++++++++ src/lib/site.ts | 5 +++ src/main.tsx | 9 +++-- src/pages/Courses.tsx | 22 +++++++++++ src/pages/FAQ.tsx | 17 +++++++++ src/pages/Maker.tsx | 5 +++ src/pages/NotFound.tsx | 2 + 10 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/components/seo/Seo.tsx create mode 100644 src/lib/site.ts diff --git a/package-lock.json b/package-lock.json index 3a2ecce..08877b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", + "react-helmet-async": "^3.0.0", "react-icons": "^5.4.0", "react-router-dom": "^7.1.1", "tailwind-merge": "^2.6.0", @@ -7392,6 +7393,15 @@ "node": ">=12" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -9676,6 +9686,26 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-helmet-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-3.0.0.tgz", + "integrity": "sha512-nA3IEZfXiclgrz4KLxAhqJqIfFDuvzQwlKwpdmzZIuC1KNSghDEIXmyU0TKtbM+NafnkICcwx8CECFrZ/sL/1w==", + "license": "Apache-2.0", + "dependencies": { + "invariant": "^2.2.4", + "react-fast-compare": "^3.2.2", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -10086,6 +10116,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 8eddf75..5c14558 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", + "react-helmet-async": "^3.0.0", "react-icons": "^5.4.0", "react-router-dom": "^7.1.1", "tailwind-merge": "^2.6.0", diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 84f3292..2692eb9 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -15,6 +15,27 @@ import { emptyGrid } from "../../utils/emptyGrid"; import mockCourses from "../../data/mockCourses"; import { saveCourses, saveCoursesOnGrid, saveCoursesUsed, saveDependencies, saveLayouts } from "../../firebase/firestore"; import { auth } from "../../firebase/firebase"; +import Seo from "../seo/Seo"; +import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "../../lib/site"; + +const siteJsonLd: Record[] = [ + { + "@context": "https://schema.org", + "@type": "WebApplication", + name: SITE_NAME, + url: SITE_URL, + description: SITE_DESCRIPTION, + applicationCategory: "EducationalApplication", + operatingSystem: "Web", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + }, + { + "@context": "https://schema.org", + "@type": "WebSite", + name: SITE_NAME, + url: SITE_URL, + }, +]; interface LayoutProps { children: ReactNode; @@ -121,6 +142,7 @@ const Layout: FC = ({ children }) => { setSavedLayouts, }} > +
diff --git a/src/components/seo/Seo.tsx b/src/components/seo/Seo.tsx new file mode 100644 index 0000000..7f16a8d --- /dev/null +++ b/src/components/seo/Seo.tsx @@ -0,0 +1,63 @@ +import { Helmet } from "react-helmet-async"; +import { + DEFAULT_OG_IMAGE, + SITE_DESCRIPTION, + SITE_NAME, + SITE_URL, +} from "../../lib/site"; + +interface SeoProps { + title?: string; + description?: string; + path?: string; + image?: string; + jsonLd?: Record | Record[]; + noindex?: boolean; +} + +const Seo = ({ + title, + description, + path, + image, + jsonLd, + noindex, +}: SeoProps) => { + const fullTitle = title ? `${title} - ${SITE_NAME}` : SITE_NAME; + const metaDescription = description ?? SITE_DESCRIPTION; + const canonical = `${SITE_URL}${path ?? "/"}`; + const ogImage = image ?? DEFAULT_OG_IMAGE; + const absoluteImage = ogImage.startsWith("http") + ? ogImage + : `${SITE_URL}${ogImage}`; + + return ( + + {fullTitle} + + {noindex && } + + + + + + + + + + + + + + + + {jsonLd && ( + + )} + + ); +}; + +export default Seo; diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..d3d99a2 --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,5 @@ +export const SITE_URL = "https://ecepathmaker.com"; +export const SITE_NAME = "ECE Pathmaker"; +export const SITE_DESCRIPTION = + "Plan your University of Toronto ECE degree by mapping out courses and prerequisites with a free drag and drop course grid."; +export const DEFAULT_OG_IMAGE = "/og/default.png"; diff --git a/src/main.tsx b/src/main.tsx index 4438c98..dc4e3a6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { AuthProvider } from './contexts/AuthContext.tsx'; +import { HelmetProvider } from 'react-helmet-async'; const storedTheme = localStorage.getItem('theme'); if (storedTheme === 'dark') { @@ -13,8 +14,10 @@ if (storedTheme === 'dark') { createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/src/pages/Courses.tsx b/src/pages/Courses.tsx index 42b9651..6b5fa2a 100644 --- a/src/pages/Courses.tsx +++ b/src/pages/Courses.tsx @@ -16,6 +16,22 @@ import { createPortal } from "react-dom"; import { FilterState } from "../types/types"; import { mockCourses } from "../utils/dataImports"; import { CourseCard, Filter } from "../utils/componentImports"; +import Seo from "../components/seo/Seo"; +import { SITE_URL } from "../lib/site"; + +const coursesJsonLd: Record = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Home", item: SITE_URL }, + { + "@type": "ListItem", + position: 2, + name: "Courses", + item: `${SITE_URL}/courses`, + }, + ], +}; const Courses = () => { const sensors = useSensors( @@ -99,6 +115,12 @@ const Courses = () => { return (
+

🗂️ Explore and rearrange courses (all the courses on Magellan and more)!

diff --git a/src/pages/FAQ.tsx b/src/pages/FAQ.tsx index 9065c45..9c3e97f 100644 --- a/src/pages/FAQ.tsx +++ b/src/pages/FAQ.tsx @@ -1,6 +1,17 @@ import { FC, useEffect, useState } from 'react'; import { siteFaqData, courseFaqData } from "../data/faqData"; import { FAQCard } from "../utils/componentImports"; +import Seo from "../components/seo/Seo"; +import { SITE_URL } from "../lib/site"; + +const faqJsonLd: Record = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Home", item: SITE_URL }, + { "@type": "ListItem", position: 2, name: "FAQ", item: `${SITE_URL}/faq` }, + ], +}; const FAQ: FC = () => { const [showButton, setShowButton] = useState(false); @@ -29,6 +40,12 @@ const FAQ: FC = () => { return (
+

FAQs