-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add SEO meta tags, canonical URLs, and sitemap #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| /* eslint-disable */ | ||
| /** | ||
| * Generated `api` utility. | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| /* eslint-disable */ | ||
| /** | ||
| * Generated data model types. | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| User-agent: * | ||
| Allow: / | ||
| Allow: / | ||
| Sitemap: https://renderdragon.org/sitemap.xml |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://renderdragon.org/</loc></url><url><loc>https://renderdragon.org/resources</loc></url><url><loc>https://renderdragon.org/blogs</loc></url><url><loc>https://renderdragon.org/guides</loc></url><url><loc>https://renderdragon.org/faq</loc></url><url><loc>https://renderdragon.org/contact</loc></url><url><loc>https://renderdragon.org/showcase</loc></url><url><loc>https://renderdragon.org/community</loc></url><url><loc>https://renderdragon.org/changelogs</loc></url><url><loc>https://renderdragon.org/utilities</loc></url><url><loc>https://renderdragon.org/generators</loc></url><url><loc>https://renderdragon.org/background-generator</loc></url><url><loc>https://renderdragon.org/text-generator</loc></url><url><loc>https://renderdragon.org/ai-title-helper</loc></url><url><loc>https://renderdragon.org/youtube-downloader</loc></url><url><loc>https://renderdragon.org/player-renderer</loc></url><url><loc>https://renderdragon.org/renderbot</loc></url><url><loc>https://renderdragon.org/native-application</loc></url><url><loc>https://renderdragon.org/tos</loc></url><url><loc>https://renderdragon.org/privacy</loc></url><url><loc>https://renderdragon.org/guides/scriptwriting</loc></url><url><loc>https://renderdragon.org/guides/AI</loc></url><url><loc>https://renderdragon.org/guides/questions</loc></url><url><loc>https://renderdragon.org/guides/copyright</loc></url><url><loc>https://renderdragon.org/guides/thingstoask</loc></url><url><loc>https://renderdragon.org/guides/voice</loc></url></urlset> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { mkdir, writeFile } from "node:fs/promises"; | ||
|
|
||
| const site = "https://renderdragon.org"; | ||
| const routes = [ | ||
| "/", "/resources", "/blogs", "/guides", "/faq", "/contact", "/showcase", | ||
| "/community", "/changelogs", "/utilities", "/generators", "/background-generator", | ||
| "/text-generator", "/ai-title-helper", "/youtube-downloader", "/player-renderer", | ||
| "/renderbot", "/native-application", "/tos", "/privacy", | ||
| "/guides/scriptwriting", "/guides/AI", "/guides/questions", "/guides/copyright", "/guides/thingstoask", "/guides/voice", | ||
| ]; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| const escapeXml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); | ||
| const urls = routes.map((route) => `<url><loc>${site}${route}</loc></url>`); | ||
|
|
||
| // Public profile and creator-pack slugs are added when build credentials are available. | ||
| if (process.env.VITE_SUPABASE_URL && process.env.VITE_SUPABASE_PUBLISHABLE_KEY) { | ||
| const headers = { apikey: process.env.VITE_SUPABASE_PUBLISHABLE_KEY }; | ||
| const fetchJson = async (path) => { | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), 8000); | ||
| try { | ||
| const response = await fetch(`${process.env.VITE_SUPABASE_URL}/rest/v1/${path}`, { headers, signal: controller.signal }); | ||
| return response.ok ? await response.json() : []; | ||
| } catch (error) { | ||
| console.warn(`Skipping sitemap enrichment for ${path}:`, error instanceof Error ? error.message : error); | ||
| return []; | ||
| } finally { | ||
| clearTimeout(timeout); | ||
| } | ||
| }; | ||
| const [profiles, packs, blogs] = await Promise.all([ | ||
| fetchJson("profiles?select=username&username=not.is.null"), | ||
| fetchJson("creator_packs?select=slug&status=eq.approved"), | ||
| fetchJson("blogs?select=slug&published=eq.true"), | ||
| ]); | ||
| for (const { username } of profiles) if (username) urls.push(`<url><loc>${site}/u/${escapeXml(username)}</loc></url>`); | ||
| for (const { slug } of packs) if (slug) urls.push(`<url><loc>${site}/creator-packs/${escapeXml(slug)}</loc></url>`); | ||
| for (const { slug } of blogs) if (slug) urls.push(`<url><loc>${site}/blogs/${escapeXml(slug)}</loc></url>`); | ||
| } | ||
|
|
||
| await mkdir("public", { recursive: true }); | ||
| await writeFile("public/sitemap.xml", `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join("")}</urlset>\n`); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ import { IconLoader2 } from "@tabler/icons-react"; | |
| import DonateButton from "@/components/DonateButton"; | ||
| import { AdBlockDetector } from "@/components/AdBlockDetector"; | ||
| import Navbar from "@/components/Navbar"; | ||
| import Seo from "@/components/Seo"; | ||
|
|
||
| const ExternalRedirect = ({ url }: { url: string }) => { | ||
| useEffect(() => { window.location.href = url; }, [url]); | ||
|
|
@@ -25,9 +26,12 @@ const GlobalComponents = () => { | |
| const location = useLocation(); | ||
| const hideDonateButton = location.pathname.startsWith('/admin') || | ||
| location.pathname.startsWith('/account'); | ||
| const routeName = location.pathname === '/' ? 'Minecraft Creator Tools & Resources' : | ||
| location.pathname.split('/').filter(Boolean).map((part) => part.split('-').join(' ')).join(' / '); | ||
|
|
||
| return ( | ||
| <> | ||
| <Seo title={`${routeName.replace(/\b\w/g, (letter) => letter.toUpperCase())} | RenderDragon`} description={`Explore ${routeName} on RenderDragon, free tools and resources for Minecraft content creators.`} path={location.pathname} /> | ||
|
Comment on lines
+29
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 3 \
'renderToString|renderToPipeableStream|prerender|ssr|createRoot|BrowserRouter|HelmetProvider' \
package.json vite.config.ts src || trueRepository: creatorcluster/renderdragon.org Length of output: 2419 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect package/scripts/types/config and relevant SEO implementation files without running repo code.
printf '--- package scripts and deps ---\n'
python3 - <<'PY'
import json, pathlib
p = pathlib.Path('package.json')
if p.exists():
data=json.loads(p.read_text())
for k in ['scripts','devDependencies','dependencies']:
print(f'[{k}]')
v=data.get(k,{})
if isinstance(v, dict):
for key,val in v.items():
if any(s in key.lower() or isinstance(val,str) and any(s in val.lower() for s in ['vite','react-router','separate','prerender','render','server','next','remix','vercel','netlify', 'render dragon'])):
print(f'{key}: {val}')
elif k=='scripts':
print(f'{key}: {val}')
PY
printf '\n--- config files ---\n'
git ls-files | rg '(^|/)(vite\.config\.(ts|js)|wrangler\.toml|netlify\.toml|vercel\.json|app\.config\.(ts|js)|render\.yaml|cypress|playwright|index\.html|sitemap|robots)' || true
printf '\n--- vite.config relevant ---\n'
if [ -f vite.config.ts ]; then
nl -ba vite.config.ts | sed -n '1,220p'
fi
if [ -f src/index.html ]; then
printf '\n--- src/index.html ---\n'
nl -ba src/index.html | sed -n '1,160p'
elif [ -f index.html ]; then
printf '\n--- index.html ---\n'
nl -ba index.html | sed -n '1,160p'
fi
printf '\n--- Seo component references ---\n'
rg -n -C 4 'function Seo|const Seo|export .*Seo|<Seo|title=|description=|path=|canonical|og:|twitter:' src || trueRepository: creatorcluster/renderdragon.org Length of output: 260 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect package metadata and build/deploy config without running repo code.
printf '%s\n' '--- package scripts and deps ---'
python3 - <<'PY'
import json, pathlib
p = pathlib.Path('package.json')
if p.exists():
data=json.loads(p.read_text())
for k in ['scripts','devDependencies','dependencies']:
print(f'[{k}]')
v=data.get(k,{})
if isinstance(v, dict):
for key,val in v.items():
if k=='scripts' or any(s in key.lower() or (isinstance(val,str) and any(s in val.lower() for s in ['vite','react-router','ssr','server','vercel','netlify','render']))):
print(f'{key}: {val}')
PY
printf '%s\n' ''
printf '%s\n' '--- config files ---'
git ls-files | rg '(^|/)(vite\.config\.(ts|js)|wrangler\.toml|netlify\.toml|vercel\.json|app\.config\.(ts|js)|render\.yaml|cypress|playwright|index\.html|sitemap|robots)' || true
printf '%s\n' ''
printf '%s\n' '--- vite.config relevant ---'
if [ -f vite.config.ts ]; then
nl -ba vite.config.ts | sed -n '1,220p'
fi
printf '%s\n' ''
if [ -f src/index.html ]; then
printf '%s\n' '--- src/index.html ---'
nl -ba src/index.html | sed -n '1,160p'
elif [ -f index.html ]; then
printf '%s\n' '--- index.html ---'
nl -ba index.html | sed -n '1,160p'
fi
printf '%s\n' ''
printf '%s\n' '--- route-name/SEO code references ---'
rg -n -C 4 'function Seo|const Seo|export .*Seo|<Seo|title=|description=|path=|canonical|og:|twitter:|renderToString|renderToString|Prerenderer|renderingRoute|ssr|server:' src || trueRepository: creatorcluster/renderdragon.org Length of output: 845 Make SEO metadata render before client JavaScript executes.
🤖 Prompt for AI Agents |
||
| {!hideDonateButton && <DonateButton />} | ||
| <AdBlockDetector /> | ||
| </> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,4 @@ | ||||||||||||||||||
| import { useState, useRef, useEffect, useCallback } from 'react'; | ||||||||||||||||||
| import WaveSurfer from 'wavesurfer.js'; | ||||||||||||||||||
| import { | ||||||||||||||||||
| IconPlayerPlay, | ||||||||||||||||||
| IconPlayerPause, | ||||||||||||||||||
|
|
@@ -23,14 +22,22 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f | |||||||||||||||||
| const [duration, setDuration] = useState(0); | ||||||||||||||||||
| const [isLoading, setIsLoading] = useState(true); | ||||||||||||||||||
| const [isReady, setIsReady] = useState(false); | ||||||||||||||||||
| const [loadError, setLoadError] = useState(false); | ||||||||||||||||||
| const [retryKey, setRetryKey] = useState(0); | ||||||||||||||||||
|
|
||||||||||||||||||
| const containerRef = useRef<HTMLDivElement>(null); | ||||||||||||||||||
| const wavesurfer = useRef<WaveSurfer | null>(null); | ||||||||||||||||||
| const wavesurfer = useRef<import('wavesurfer.js').default | null>(null); | ||||||||||||||||||
|
|
||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||
| if (!containerRef.current) return; | ||||||||||||||||||
|
|
||||||||||||||||||
| const ws = WaveSurfer.create({ | ||||||||||||||||||
| let ws: import('wavesurfer.js').default | null = null; | ||||||||||||||||||
| let isMounted = true; | ||||||||||||||||||
| setLoadError(false); | ||||||||||||||||||
| setIsReady(false); | ||||||||||||||||||
|
Comment on lines
+36
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reset playback state when the source changes. Line 36 resets readiness but retains Proposed fix setLoadError(false);
setIsReady(false);
+ setIsPlaying(false);
+ setCurrentTime(0);
+ setDuration(0);
+ setIsLoading(!allowPlayBeforeReady);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| import('wavesurfer.js').then(({ default: WaveSurfer }) => { | ||||||||||||||||||
| if (!isMounted || !containerRef.current) return; | ||||||||||||||||||
| ws = WaveSurfer.create({ | ||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||
| container: containerRef.current, | ||||||||||||||||||
| waveColor: 'rgba(139, 92, 246, 0.2)', // Soft cow-purple | ||||||||||||||||||
| progressColor: '#8b5cf6', // Solid cow-purple | ||||||||||||||||||
|
|
@@ -42,42 +49,52 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f | |||||||||||||||||
| barGap: 3, | ||||||||||||||||||
| normalize: true, | ||||||||||||||||||
| hideScrollbar: true, | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| let isMounted = true; | ||||||||||||||||||
| wavesurfer.current = ws; | ||||||||||||||||||
| }); | ||||||||||||||||||
| wavesurfer.current = ws; | ||||||||||||||||||
|
|
||||||||||||||||||
| if (allowPlayBeforeReady) { | ||||||||||||||||||
| setIsLoading(false); | ||||||||||||||||||
| } else { | ||||||||||||||||||
| setIsLoading(true); | ||||||||||||||||||
| } | ||||||||||||||||||
| ws.load(src).catch((err) => { | ||||||||||||||||||
| ws.load(src).catch((err) => { | ||||||||||||||||||
| if (err.name === 'AbortError') return; | ||||||||||||||||||
| console.error('WaveSurfer load error:', err); | ||||||||||||||||||
| }); | ||||||||||||||||||
| if (isMounted) { | ||||||||||||||||||
| setIsLoading(false); | ||||||||||||||||||
| setLoadError(true); | ||||||||||||||||||
| } | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| ws.on('ready', () => { | ||||||||||||||||||
| ws.on('ready', () => { | ||||||||||||||||||
| if (!isMounted) return; | ||||||||||||||||||
| setDuration(ws.getDuration()); | ||||||||||||||||||
| setIsLoading(false); | ||||||||||||||||||
| setIsReady(true); | ||||||||||||||||||
| }); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| ws.on('audioprocess', () => { | ||||||||||||||||||
| ws.on('audioprocess', () => { | ||||||||||||||||||
| if (!isMounted) return; | ||||||||||||||||||
| setCurrentTime(ws.getCurrentTime()); | ||||||||||||||||||
| }); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| ws.on('play', () => isMounted && setIsPlaying(true)); | ||||||||||||||||||
| ws.on('pause', () => isMounted && setIsPlaying(false)); | ||||||||||||||||||
| ws.on('finish', () => isMounted && setIsPlaying(false)); | ||||||||||||||||||
| ws.on('play', () => isMounted && setIsPlaying(true)); | ||||||||||||||||||
| ws.on('pause', () => isMounted && setIsPlaying(false)); | ||||||||||||||||||
| ws.on('finish', () => isMounted && setIsPlaying(false)); | ||||||||||||||||||
| }).catch((error: unknown) => { | ||||||||||||||||||
| if (!isMounted) return; | ||||||||||||||||||
| console.error('Failed to load WaveSurfer:', error); | ||||||||||||||||||
| setIsLoading(false); | ||||||||||||||||||
| setLoadError(true); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| return () => { | ||||||||||||||||||
| isMounted = false; | ||||||||||||||||||
| ws.destroy(); | ||||||||||||||||||
| setIsReady(false); | ||||||||||||||||||
| if (wavesurfer.current === ws) wavesurfer.current = null; | ||||||||||||||||||
| ws?.destroy(); | ||||||||||||||||||
| }; | ||||||||||||||||||
| }, [src]); | ||||||||||||||||||
| }, [src, allowPlayBeforeReady, retryKey]); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Handle visibility | ||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||
|
|
@@ -132,6 +149,12 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f | |||||||||||||||||
| </div> | ||||||||||||||||||
| )} | ||||||||||||||||||
| <div ref={containerRef} className="w-full" /> | ||||||||||||||||||
| {loadError && ( | ||||||||||||||||||
| <div className="absolute inset-0 flex items-center justify-center gap-2 bg-card/90 text-sm text-muted-foreground"> | ||||||||||||||||||
| <span>Audio preview unavailable.</span> | ||||||||||||||||||
| <Button variant="outline" size="sm" onClick={() => setRetryKey((key) => key + 1)}>Retry</Button> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| )} | ||||||||||||||||||
| </div> | ||||||||||||||||||
|
|
||||||||||||||||||
| {/* Controls and Info */} | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,8 @@ import ResourceCard from "@/components/resources/ResourceCard"; | |
| import ResourceCardSkeleton from "./resources/ResourceCardSkeleton"; | ||
|
|
||
| const FEATURED_CATEGORIES = ["music", "images", "sfx", "fonts"]; | ||
| type RawResource = Record<string, unknown>; | ||
| const stringValue = (value: unknown): string | undefined => typeof value === "string" && value.trim() ? value : undefined; | ||
|
|
||
| const FeaturedResources = () => { | ||
| const [featuredResources, setFeaturedResources] = useState<Resource[]>([]); | ||
|
|
@@ -32,18 +34,15 @@ const FeaturedResources = () => { | |
| const rawItems = await catRes.json(); | ||
| const resources: Resource[] = (Array.isArray(rawItems) ? rawItems : []) | ||
| .slice(0, 4) | ||
| .map((item: any, idx: number) => ({ | ||
| id: item.id ?? `${catKeys[0]}-${idx}`, | ||
| title: String(item?.title || "").trim() || `Resource ${idx + 1}`, | ||
| .map((item: RawResource, idx: number) => ({ | ||
| id: typeof item.id === "number" || typeof item.id === "string" ? item.id : `${catKeys[0]}-${idx}`, | ||
| title: stringValue(item.title) || `Resource ${idx + 1}`, | ||
| category: catKeys[0] as Resource["category"], | ||
| subcategory: item.subcategory || undefined, | ||
| credit: item.credit || undefined, | ||
| filetype: item.filetype || item.ext || undefined, | ||
| download_url: item.download_url || item.url || undefined, | ||
| preview_url: item.preview_url || undefined, | ||
| image_url: item.image_url || undefined, | ||
| software: item.software || undefined, | ||
| description: item.description || undefined, | ||
| subcategory: stringValue(item.subcategory), credit: stringValue(item.credit), | ||
| filetype: stringValue(item.filetype) || stringValue(item.ext), | ||
| download_url: stringValue(item.download_url) || stringValue(item.url), | ||
| preview_url: stringValue(item.preview_url), image_url: stringValue(item.image_url), | ||
| software: stringValue(item.software), description: stringValue(item.description), | ||
|
Comment on lines
+37
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Validate each resource entry before reading its fields.
Filter object records before Proposed fix- const resources: Resource[] = (Array.isArray(rawItems) ? rawItems : [])
+ const resources: Resource[] = (Array.isArray(rawItems) ? rawItems : [])
+ .filter((item): item is RawResource => item !== null && typeof item === "object")
.slice(0, 4)🤖 Prompt for AI Agents |
||
| })); | ||
|
|
||
| setFeaturedResources(resources); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { Helmet } from "react-helmet-async"; | ||
|
|
||
| const SITE_URL = "https://renderdragon.org"; | ||
|
|
||
| interface SeoProps { | ||
| title: string; | ||
| description: string; | ||
| path: string; | ||
| image?: string; | ||
| } | ||
|
|
||
| export default function Seo({ title, description, path, image = "/ogimg.png" }: SeoProps) { | ||
| const normalizedPath = path.replace(/\/+$/, "") || "/"; | ||
| const canonical = `${SITE_URL}${normalizedPath === "/" ? "" : normalizedPath}`; | ||
| const imageUrl = image.startsWith("http") ? image : `${SITE_URL}${image}`; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| return <Helmet> | ||
| <title>{title}</title> | ||
| <meta name="description" content={description} /> | ||
| <link rel="canonical" href={canonical} /> | ||
| <meta property="og:title" content={title} /> | ||
| <meta property="og:description" content={description} /> | ||
| <meta property="og:url" content={canonical} /> | ||
| <meta property="og:type" content="website" /> | ||
| <meta property="og:image" content={imageUrl} /> | ||
| <meta name="twitter:card" content="summary_large_image" /> | ||
| <meta name="twitter:title" content={title} /> | ||
| <meta name="twitter:description" content={description} /> | ||
| <meta name="twitter:image" content={imageUrl} /> | ||
| </Helmet>; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.