fix(seo): resolve 7 SEO issues from GitHub Issue #4

SEO improvements for better search engine optimization:

  1. Canonical URL - Added client-side dynamic canonical link tags for posts and pages
  2. Single H1 per page - Markdown H1s demoted to H2 with .blog-h1-demoted class (maintains H1 visual styling)
  3. DOM order fix - Article now loads before sidebar in DOM for SEO (CSS order property maintains visual layout)
  4. X-Robots-Tag - HTTP header added via netlify.toml (index, follow for public; noindex for dashboard/api routes)
  5. Hreflang tags - Self-referencing hreflang (en, x-default) for language targeting
  6. og:url consistency - Uses same canonicalUrl variable as canonical link tag
  7. twitter:site - New TwitterConfig in siteConfig.ts for Twitter Cards meta tags

  Files modified:
  - src/config/siteConfig.ts: Added TwitterConfig interface with site/creator fields
  - src/pages/Post.tsx: SEO meta tags for posts/pages, DOM order optimization
  - src/components/BlogPost.tsx: H1 to H2 demotion in markdown renderer
  - src/styles/global.css: .blog-h1-demoted class, CSS order properties
  - convex/http.ts: hreflang and twitter:site in generateMetaHtml()
  - netlify.toml: X-Robots-Tag headers for public, dashboard, API routes
  - index.html: canonical, hreflang, twitter:site placeholder tags
  - fork-config.json.example: twitter configuration fields

  Closes #4

  Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Wayne Sutton
2026-01-06 11:31:55 -08:00
parent 85c100451a
commit 98916899a3
12 changed files with 317 additions and 32 deletions

12
TASK.md
View File

@@ -4,10 +4,20 @@
## Current Status ## Current Status
v2.10.1 ready. Semantic search now optional via siteConfig.semanticSearch.enabled toggle. v2.10.2 ready. SEO fixes from GitHub Issue #4 implemented.
## Completed ## Completed
- [x] SEO fixes for GitHub Issue #4 (7 issues)
- [x] Canonical URL: Dynamic canonical link tags for posts and pages in Post.tsx
- [x] Single H1 per page: Markdown H1s demoted to H2 with `.blog-h1-demoted` class in BlogPost.tsx
- [x] DOM order fix: Article before sidebar in DOM, CSS `order` for visual positioning
- [x] X-Robots-Tag: HTTP header in netlify.toml (index for public, noindex for dashboard/api)
- [x] Hreflang tags: Self-referencing hreflang (en, x-default) in index.html, Post.tsx, http.ts
- [x] og:url consistency: Uses same canonicalUrl variable as canonical link
- [x] twitter:site: New TwitterConfig in siteConfig.ts with site and creator fields
- [x] Updated fork-config.json.example with twitter configuration
- [x] Optional semantic search configuration - [x] Optional semantic search configuration
- [x] Added `SemanticSearchConfig` interface to `siteConfig.ts` - [x] Added `SemanticSearchConfig` interface to `siteConfig.ts`
- [x] Added `semanticSearch.enabled` toggle (default: false to avoid blocking forks) - [x] Added `semanticSearch.enabled` toggle (default: false to avoid blocking forks)

View File

@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [2.10.2] - 2026-01-06
### Added
- SEO fixes for GitHub Issue #4 (7 issues resolved)
- Canonical URL: Client-side dynamic canonical link tags for posts and pages
- Single H1 per page: Markdown H1s demoted to H2 (`.blog-h1-demoted` class with H1 visual styling)
- DOM order fix: Article loads before sidebar in DOM for SEO (CSS `order` property maintains visual layout)
- X-Robots-Tag: HTTP header added via netlify.toml (`index, follow` for public, `noindex` for dashboard/api)
- Hreflang tags: Self-referencing hreflang (en, x-default) for all pages
- og:url consistency: Uses same canonicalUrl variable as canonical link
- twitter:site meta tag: New TwitterConfig in siteConfig.ts for Twitter Cards
### Technical
- New `TwitterConfig` interface in `src/config/siteConfig.ts` with site and creator fields
- Updated `src/pages/Post.tsx` with SEO meta tags for both posts and pages (canonical, hreflang, og:url, twitter)
- Updated `src/pages/Post.tsx` DOM order: article before sidebar with CSS order for visual positioning
- Updated `src/components/BlogPost.tsx` h1 renderer outputs h2 with `.blog-h1-demoted` class
- Updated `src/styles/global.css` with `.blog-h1-demoted` styling and CSS order properties for sidebar
- Updated `convex/http.ts` generateMetaHtml() with hreflang and twitter:site tags
- Updated `netlify.toml` with X-Robots-Tag headers for public, dashboard, and API routes
- Updated `index.html` with canonical, hreflang, and twitter:site placeholder tags
- Updated `fork-config.json.example` with twitter configuration fields
## [2.10.1] - 2026-01-05 ## [2.10.1] - 2026-01-05
### Added ### Added

View File

@@ -11,6 +11,35 @@ docsSectionOrder: 4
All notable changes to this project. All notable changes to this project.
## v2.10.2
Released January 6, 2026
**SEO fixes for GitHub Issue #4**
Seven SEO issues resolved to improve search engine optimization:
1. **Canonical URL** - Dynamic canonical link tags added client-side for posts and pages
2. **Single H1 per page** - Markdown H1s demoted to H2 elements with `.blog-h1-demoted` class (maintains H1 visual styling)
3. **DOM order fix** - Article now loads before sidebar in DOM for better SEO (CSS `order` property maintains visual layout)
4. **X-Robots-Tag** - HTTP header added via netlify.toml (public routes indexed, dashboard/API routes noindexed)
5. **Hreflang tags** - Self-referencing hreflang (en, x-default) for language targeting
6. **og:url consistency** - Uses same canonicalUrl variable as canonical link tag
7. **twitter:site** - New `TwitterConfig` in siteConfig.ts for Twitter Cards
**Configuration:**
Add your Twitter handle in `src/config/siteConfig.ts`:
```typescript
twitter: {
site: "@yourhandle",
creator: "@yourhandle",
},
```
**Updated files:** `src/config/siteConfig.ts`, `src/pages/Post.tsx`, `src/components/BlogPost.tsx`, `src/styles/global.css`, `convex/http.ts`, `netlify.toml`, `index.html`, `fork-config.json.example`
## v2.10.1 ## v2.10.1
Released January 5, 2026 Released January 5, 2026

View File

@@ -302,12 +302,18 @@ function generateMetaHtml(content: {
: "" : ""
} }
<!-- Hreflang for language/region targeting -->
<link rel="alternate" hreflang="en" href="${canonicalUrl}">
<link rel="alternate" hreflang="x-default" href="${canonicalUrl}">
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${safeTitle}"> <meta name="twitter:title" content="${safeTitle}">
<meta name="twitter:description" content="${safeDescription}"> <meta name="twitter:description" content="${safeDescription}">
<meta name="twitter:image" content="${ogImage}"> <meta name="twitter:image" content="${ogImage}">
<meta name="twitter:site" content="">
<meta name="twitter:creator" content="">
<!-- Redirect to actual page after a brief delay for crawlers --> <!-- Redirect to actual page after a brief delay for crawlers -->
<script> <script>
setTimeout(() => { setTimeout(() => {

View File

@@ -35,7 +35,7 @@ A brief description of each file in the codebase.
| File | Description | | File | Description |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, featured section with configurable title via featuredTitle, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration with markdown support, social footer configuration, homepage configuration, AI chat configuration, aiDashboard configuration with multi-model support for text chat and image generation, newsletter configuration with admin and notifications, contact form configuration, weekly digest configuration, stats page configuration with public/private toggle, dashboard configuration with optional WorkOS authentication via requireAuth, image lightbox configuration with enabled toggle, semantic search configuration with enabled toggle and disabled by default to avoid blocking forks without OPENAI_API_KEY) | | `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, featured section with configurable title via featuredTitle, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration with markdown support, social footer configuration, homepage configuration, AI chat configuration, aiDashboard configuration with multi-model support for text chat and image generation, newsletter configuration with admin and notifications, contact form configuration, weekly digest configuration, stats page configuration with public/private toggle, dashboard configuration with optional WorkOS authentication via requireAuth, image lightbox configuration with enabled toggle, semantic search configuration with enabled toggle and disabled by default to avoid blocking forks without OPENAI_API_KEY, twitter configuration for Twitter Cards meta tags) |
### Pages (`src/pages/`) ### Pages (`src/pages/`)
@@ -43,7 +43,7 @@ A brief description of each file in the codebase.
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Home.tsx` | Landing page with featured content and optional post list. Fetches home intro content from `content/pages/home.md` (slug: `home-intro`) for synced markdown intro text. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay. Falls back to siteConfig.bio if home-intro page not found. Home intro content uses blog heading styles (blog-h1 through blog-h6) with clickable anchor links, matching blog post typography. Includes helper functions (generateSlug, getTextContent, HeadingAnchor) for heading ID generation and anchor links. Featured section title configurable via siteConfig.featuredTitle (default: "Get started:"). | | `Home.tsx` | Landing page with featured content and optional post list. Fetches home intro content from `content/pages/home.md` (slug: `home-intro`) for synced markdown intro text. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay. Falls back to siteConfig.bio if home-intro page not found. Home intro content uses blog heading styles (blog-h1 through blog-h6) with clickable anchor links, matching blog post typography. Includes helper functions (generateSlug, getTextContent, HeadingAnchor) for heading ID generation and anchor links. Featured section title configurable via siteConfig.featuredTitle (default: "Get started:"). |
| `Blog.tsx` | Dedicated blog page with featured layout: hero post (first blogFeatured), featured row (remaining blogFeatured in 2 columns with excerpts), and regular posts (3 columns without excerpts). Supports list/card view toggle. Includes back button in navigation | | `Blog.tsx` | Dedicated blog page with featured layout: hero post (first blogFeatured), featured row (remaining blogFeatured in 2 columns with excerpts), and regular posts (3 columns without excerpts). Supports list/card view toggle. Includes back button in navigation |
| `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button (hidden when used as homepage), tag links, related posts section in footer for blog posts, footer component with markdown support (fetches footer.md content from Convex), and social footer. Supports 3-column layout at 1135px+. Can display image at top when showImageAtTop: true. Can be used as custom homepage via siteConfig.homepage (update SITE_URL/SITE_NAME when forking) | | `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button (hidden when used as homepage), tag links, related posts section in footer for blog posts, footer component with markdown support (fetches footer.md content from Convex), and social footer. Supports 3-column layout at 1135px+. Can display image at top when showImageAtTop: true. Can be used as custom homepage via siteConfig.homepage (update SITE_URL/SITE_NAME when forking). SEO: Dynamic canonical URL, hreflang tags, og:url consistency, and twitter:site meta tags. DOM order optimized for SEO (article before sidebar, CSS order for visual layout). |
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars. Configurable via `siteConfig.statsPage` to enable/disable public access and navigation visibility. Shows disabled message when `enabled: false` (similar to NewsletterAdmin pattern). | | `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars. Configurable via `siteConfig.statsPage` to enable/disable public access and navigation visibility. Shows disabled message when `enabled: false` (similar to NewsletterAdmin pattern). |
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence | | `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
| `AuthorPage.tsx` | Author archive page displaying posts by a specific author. Includes view mode toggle (list/cards) with localStorage persistence. Author name clickable in posts links to this page. | | `AuthorPage.tsx` | Author archive page displaying posts by a specific author. Includes view mode toggle (list/cards) with localStorage persistence. Author name clickable in posts links to this page. |
@@ -60,7 +60,7 @@ A brief description of each file in the codebase.
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) | | `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes, columns prop for 2/3 column grids, showExcerpts prop to control excerpt visibility) | | `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes, columns prop for 2/3 column grids, showExcerpts prop to control excerpt visibility) |
| `BlogHeroCard.tsx` | Hero card component for the first blogFeatured post on blog page. Displays landscape image, tags, date, title, excerpt, author info, and read more link | | `BlogHeroCard.tsx` | Hero card component for the first blogFeatured post on blog page. Displays landscape image, tags, date, title, excerpt, author info, and read more link |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), text wrapping for plain text code blocks, image lightbox support (click images to magnify in full-screen overlay), and iframe embed support with domain whitelisting (YouTube and Twitter/X only) | | `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), text wrapping for plain text code blocks, image lightbox support (click images to magnify in full-screen overlay), and iframe embed support with domain whitelisting (YouTube and Twitter/X only). SEO: H1 headings in markdown demoted to H2 (`.blog-h1-demoted` class) for single H1 per page compliance. |
| `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), and Open in AI links (ChatGPT, Claude, Perplexity) using local /raw URLs with simplified prompt | | `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), and Open in AI links (ChatGPT, Claude, Perplexity) using local /raw URLs with simplified prompt |
| `Footer.tsx` | Footer component that renders markdown content from frontmatter footer field or siteConfig.defaultContent. Can be enabled/disabled globally and per-page via frontmatter showFooter field. Renders inside article at bottom for posts/pages, and in current position on homepage. Supports images with size control via HTML attributes (width, height, style, class) | | `Footer.tsx` | Footer component that renders markdown content from frontmatter footer field or siteConfig.defaultContent. Can be enabled/disabled globally and per-page via frontmatter showFooter field. Renders inside article at bottom for posts/pages, and in current position on homepage. Supports images with size control via HTML attributes (width, height, style, class) |
| `SearchModal.tsx` | Full text search modal with keyboard navigation. Supports keyword and semantic search modes (toggle with Tab). Semantic mode conditionally shown when `siteConfig.semanticSearch.enabled: true`. When semantic disabled (default), shows keyword search only without mode toggle. | | `SearchModal.tsx` | Full text search modal with keyboard navigation. Supports keyword and semantic search modes (toggle with Tab). Semantic mode conditionally shown when `siteConfig.semanticSearch.enabled: true`. When semantic disabled (default), shows keyword search only without mode toggle. |
@@ -103,7 +103,7 @@ A brief description of each file in the codebase.
| File | Description | | File | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `global.css` | Global CSS with theme variables, centralized font-size CSS variables for all themes, sidebar styling with alternate background colors, hidden scrollbar, and consistent borders using box-shadow for docs-style layout. Left sidebar (`.post-sidebar-wrapper`) and right sidebar (`.post-sidebar-right`) have separate, independent styles. Footer image styles (`.site-footer-image-wrapper`, `.site-footer-image`, `.site-footer-image-caption`) for responsive image display. Write page layout uses viewport height constraints (100vh) with overflow hidden to prevent page scroll, and AI chat uses flexbox with min-height: 0 for proper scrollable message area. Image lightbox styles (`.image-lightbox-backdrop`, `.image-lightbox-img`, `.image-lightbox-close`, `.image-lightbox-caption`) for full-screen image magnification with backdrop, close button, and caption display | | `global.css` | Global CSS with theme variables, centralized font-size CSS variables for all themes, sidebar styling with alternate background colors, hidden scrollbar, and consistent borders using box-shadow for docs-style layout. Left sidebar (`.post-sidebar-wrapper`) and right sidebar (`.post-sidebar-right`) have separate, independent styles. Footer image styles (`.site-footer-image-wrapper`, `.site-footer-image`, `.site-footer-image-caption`) for responsive image display. Write page layout uses viewport height constraints (100vh) with overflow hidden to prevent page scroll, and AI chat uses flexbox with min-height: 0 for proper scrollable message area. Image lightbox styles (`.image-lightbox-backdrop`, `.image-lightbox-img`, `.image-lightbox-close`, `.image-lightbox-caption`) for full-screen image magnification with backdrop, close button, and caption display. SEO: `.blog-h1-demoted` class for demoted H1s (semantic H2 with H1 styling), CSS `order` properties for article/sidebar DOM order optimization |
## Convex Backend (`convex/`) ## Convex Backend (`convex/`)
@@ -121,7 +121,7 @@ A brief description of each file in the codebase.
| `embeddingsQueries.ts` | Internal queries and mutations for embedding storage and retrieval | | `embeddingsQueries.ts` | Internal queries and mutations for embedding storage and retrieval |
| `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat | | `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat |
| `crons.ts` | Cron jobs for stale session cleanup (every 5 minutes), weekly newsletter digest (Sundays 9am UTC), and weekly stats summary (Mondays 9am UTC). Uses environment variables SITE_URL and SITE_NAME for email content. | | `crons.ts` | Cron jobs for stale session cleanup (every 5 minutes), weekly newsletter digest (Sundays 9am UTC), and weekly stats summary (Mondays 9am UTC). Uses environment variables SITE_URL and SITE_NAME for email content. |
| `http.ts` | HTTP endpoints: sitemap (includes tag pages), API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast), Open Graph HTML generation for social crawlers | | `http.ts` | HTTP endpoints: sitemap (includes tag pages), API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast), Open Graph HTML generation for social crawlers with hreflang and twitter:site meta tags |
| `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) | | `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) |
| `auth.config.ts` | Convex authentication configuration for WorkOS. Defines JWT providers for WorkOS API and user management. Requires WORKOS_CLIENT_ID environment variable in Convex. Optional - only needed if using WorkOS authentication for dashboard. | | `auth.config.ts` | Convex authentication configuration for WorkOS. Defines JWT providers for WorkOS API and user management. Requires WORKOS_CLIENT_ID environment variable in Convex. Optional - only needed if using WorkOS authentication for dashboard. |
| `aiChats.ts` | Queries and mutations for AI chat history (per-session, per-context storage). Handles anonymous session IDs, per-page chat contexts, and message history management. Supports page content as context for AI responses. | | `aiChats.ts` | Queries and mutations for AI chat history (per-session, per-context storage). Handles anonymous session IDs, per-page chat contexts, and message history management. Supports page content as context for AI responses. |

View File

@@ -182,6 +182,10 @@
}, },
"semanticSearch": { "semanticSearch": {
"enabled": false "enabled": false
},
"twitter": {
"site": "@yourhandle",
"creator": "@yourhandle"
} }
} }

View File

@@ -21,6 +21,13 @@
/> />
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<!-- Canonical URL -->
<link rel="canonical" href="https://www.markdown.fast/" />
<!-- Hreflang for language/region targeting -->
<link rel="alternate" hreflang="en" href="https://www.markdown.fast/" />
<link rel="alternate" hreflang="x-default" href="https://www.markdown.fast/" />
<!-- Theme --> <!-- Theme -->
<meta name="theme-color" content="#faf8f5" /> <meta name="theme-color" content="#faf8f5" />
@@ -54,6 +61,9 @@
name="twitter:image" name="twitter:image"
content="https://www.markdown.fast/images/og-default.png" content="https://www.markdown.fast/images/og-default.png"
/> />
<!-- twitter:site - configure in siteConfig.ts for your Twitter handle -->
<meta name="twitter:site" content="" />
<meta name="twitter:creator" content="" />
<!-- RSS Feeds --> <!-- RSS Feeds -->
<link <link

View File

@@ -92,8 +92,21 @@
X-Content-Type-Options = "nosniff" X-Content-Type-Options = "nosniff"
X-XSS-Protection = "1; mode=block" X-XSS-Protection = "1; mode=block"
Referrer-Policy = "strict-origin-when-cross-origin" Referrer-Policy = "strict-origin-when-cross-origin"
X-Robots-Tag = "index, follow"
# Link header removed from global scope to avoid applying to /raw/* # Link header removed from global scope to avoid applying to /raw/*
# Dashboard - prevent indexing of admin pages
[[headers]]
for = "/dashboard/*"
[headers.values]
X-Robots-Tag = "noindex, nofollow"
# API endpoints - prevent indexing
[[headers]]
for = "/api/*"
[headers.values]
X-Robots-Tag = "noindex"
# Link header for SPA entry point only # Link header for SPA entry point only
[[headers]] [[headers]]
for = "/index.html" for = "/index.html"

View File

@@ -643,12 +643,14 @@ export default function BlogPost({
); );
}, },
h1({ children }) { h1({ children }) {
// Demote H1 in markdown content to H2 since page title is the H1
// This ensures only one H1 per page for better SEO
const id = generateSlug(getTextContent(children)); const id = generateSlug(getTextContent(children));
return ( return (
<h1 id={id} className="blog-h1"> <h2 id={id} className="blog-h1-demoted">
<HeadingAnchor id={id} /> <HeadingAnchor id={id} />
{children} {children}
</h1> </h2>
); );
}, },
h2({ children }) { h2({ children }) {
@@ -931,12 +933,14 @@ export default function BlogPost({
); );
}, },
h1({ children }) { h1({ children }) {
// Demote H1 in markdown content to H2 since page title is the H1
// This ensures only one H1 per page for better SEO
const id = generateSlug(getTextContent(children)); const id = generateSlug(getTextContent(children));
return ( return (
<h1 id={id} className="blog-h1"> <h2 id={id} className="blog-h1-demoted">
<HeadingAnchor id={id} /> <HeadingAnchor id={id} />
{children} {children}
</h1> </h2>
); );
}, },
h2({ children }) { h2({ children }) {

View File

@@ -243,6 +243,13 @@ export interface SemanticSearchConfig {
enabled: boolean; // Global toggle for semantic search feature enabled: boolean; // Global toggle for semantic search feature
} }
// Twitter/X configuration for Twitter Cards
// Used for twitter:site and twitter:creator meta tags
export interface TwitterConfig {
site?: string; // @username for the website (e.g., "@yoursite")
creator?: string; // @username for default content creator
}
// Social link configuration for social footer // Social link configuration for social footer
export interface SocialLink { export interface SocialLink {
platform: platform:
@@ -375,6 +382,9 @@ export interface SiteConfig {
// Semantic search configuration (optional) // Semantic search configuration (optional)
semanticSearch?: SemanticSearchConfig; semanticSearch?: SemanticSearchConfig;
// Twitter/X configuration (optional)
twitter?: TwitterConfig;
} }
// Default site configuration // Default site configuration
@@ -761,6 +771,14 @@ export const siteConfig: SiteConfig = {
semanticSearch: { semanticSearch: {
enabled: false, // Set to true to enable semantic search (requires OPENAI_API_KEY) enabled: false, // Set to true to enable semantic search (requires OPENAI_API_KEY)
}, },
// Twitter/X configuration for Twitter Cards
// Set your Twitter handle for twitter:site meta tag
// Leave empty if you don't want to include twitter:site
twitter: {
site: "", // Your Twitter handle (e.g., "@yoursite")
creator: "", // Default creator handle
},
}; };
// Export the config as default for easy importing // Export the config as default for easy importing

View File

@@ -227,6 +227,53 @@ export default function Post({
updateMeta('meta[name="twitter:image"]', "content", ogImage); updateMeta('meta[name="twitter:image"]', "content", ogImage);
updateMeta('meta[name="twitter:card"]', "content", "summary_large_image"); updateMeta('meta[name="twitter:card"]', "content", "summary_large_image");
// Update twitter:site and twitter:creator if configured
if (siteConfig.twitter?.site) {
updateMeta('meta[name="twitter:site"]', "content", siteConfig.twitter.site);
}
if (siteConfig.twitter?.creator || post.authorTwitter) {
updateMeta(
'meta[name="twitter:creator"]',
"content",
post.authorTwitter || siteConfig.twitter?.creator || "",
);
}
// Update canonical URL
const canonicalUrl = postUrl;
let canonicalLink = document.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
if (!canonicalLink) {
canonicalLink = document.createElement("link");
canonicalLink.setAttribute("rel", "canonical");
document.head.appendChild(canonicalLink);
}
canonicalLink.setAttribute("href", canonicalUrl);
// Update hreflang tags for SEO
let hreflangEn = document.querySelector(
'link[hreflang="en"]',
) as HTMLLinkElement | null;
if (!hreflangEn) {
hreflangEn = document.createElement("link");
hreflangEn.setAttribute("rel", "alternate");
hreflangEn.setAttribute("hreflang", "en");
document.head.appendChild(hreflangEn);
}
hreflangEn.setAttribute("href", canonicalUrl);
let hreflangDefault = document.querySelector(
'link[hreflang="x-default"]',
) as HTMLLinkElement | null;
if (!hreflangDefault) {
hreflangDefault = document.createElement("link");
hreflangDefault.setAttribute("rel", "alternate");
hreflangDefault.setAttribute("hreflang", "x-default");
document.head.appendChild(hreflangDefault);
}
hreflangDefault.setAttribute("href", canonicalUrl);
// Cleanup on unmount // Cleanup on unmount
return () => { return () => {
const scriptEl = document.getElementById("json-ld-article"); const scriptEl = document.getElementById("json-ld-article");
@@ -234,6 +281,91 @@ export default function Post({
}; };
}, [post, page]); }, [post, page]);
// Inject SEO meta tags for static pages (canonical, hreflang, og:url, twitter)
useEffect(() => {
if (!page || post) return; // Only run for pages, not posts
const pageUrl = `${SITE_URL}/${page.slug}`;
const ogImage = page.image
? page.image.startsWith("http")
? page.image
: `${SITE_URL}${page.image}`
: `${SITE_URL}${DEFAULT_OG_IMAGE}`;
// Helper to update or create meta tag
const updateMeta = (selector: string, attr: string, value: string) => {
let meta = document.querySelector(selector);
if (!meta) {
meta = document.createElement("meta");
const attrName = selector.includes("property=") ? "property" : "name";
const attrValue = selector.match(/["']([^"']+)["']/)?.[1] || "";
meta.setAttribute(attrName, attrValue);
document.head.appendChild(meta);
}
meta.setAttribute(attr, value);
};
// Update meta description
const description = page.excerpt || `${page.title} - ${SITE_NAME}`;
updateMeta('meta[name="description"]', "content", description);
// Update Open Graph meta tags
updateMeta('meta[property="og:title"]', "content", page.title);
updateMeta('meta[property="og:description"]', "content", description);
updateMeta('meta[property="og:url"]', "content", pageUrl);
updateMeta('meta[property="og:image"]', "content", ogImage);
updateMeta('meta[property="og:type"]', "content", "website");
// Update Twitter Card meta tags
updateMeta('meta[name="twitter:title"]', "content", page.title);
updateMeta('meta[name="twitter:description"]', "content", description);
updateMeta('meta[name="twitter:image"]', "content", ogImage);
updateMeta('meta[name="twitter:card"]', "content", "summary_large_image");
// Update twitter:site and twitter:creator if configured
if (siteConfig.twitter?.site) {
updateMeta('meta[name="twitter:site"]', "content", siteConfig.twitter.site);
}
if (siteConfig.twitter?.creator) {
updateMeta('meta[name="twitter:creator"]', "content", siteConfig.twitter.creator);
}
// Update canonical URL
const canonicalUrl = pageUrl;
let canonicalLink = document.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
if (!canonicalLink) {
canonicalLink = document.createElement("link");
canonicalLink.setAttribute("rel", "canonical");
document.head.appendChild(canonicalLink);
}
canonicalLink.setAttribute("href", canonicalUrl);
// Update hreflang tags for SEO
let hreflangEn = document.querySelector(
'link[hreflang="en"]',
) as HTMLLinkElement | null;
if (!hreflangEn) {
hreflangEn = document.createElement("link");
hreflangEn.setAttribute("rel", "alternate");
hreflangEn.setAttribute("hreflang", "en");
document.head.appendChild(hreflangEn);
}
hreflangEn.setAttribute("href", canonicalUrl);
let hreflangDefault = document.querySelector(
'link[hreflang="x-default"]',
) as HTMLLinkElement | null;
if (!hreflangDefault) {
hreflangDefault = document.createElement("link");
hreflangDefault.setAttribute("rel", "alternate");
hreflangDefault.setAttribute("hreflang", "x-default");
document.head.appendChild(hreflangDefault);
}
hreflangDefault.setAttribute("href", canonicalUrl);
}, [page, post]);
// Check if we're loading a docs page - keep layout mounted to prevent flash // Check if we're loading a docs page - keep layout mounted to prevent flash
const isDocsRoute = siteConfig.docsSection?.enabled && slug; const isDocsRoute = siteConfig.docsSection?.enabled && slug;
@@ -347,17 +479,8 @@ export default function Post({
<div <div
className={`${hasAnySidebar ? "post-content-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-content-right-sidebar-only" : ""}`} className={`${hasAnySidebar ? "post-content-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-content-right-sidebar-only" : ""}`}
> >
{/* Left sidebar - TOC */} {/* Main content - placed first in DOM for SEO (H1 loads before sidebar H3) */}
{hasLeftSidebar && ( {/* CSS order property handles visual positioning (sidebar on left) */}
<aside className="post-sidebar-wrapper post-sidebar-left">
<PageSidebar
headings={headings}
activeId={location.hash.slice(1)}
/>
</aside>
)}
{/* Main content */}
<article <article
className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-article-centered" : ""}`} className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-article-centered" : ""}`}
> >
@@ -444,6 +567,17 @@ export default function Post({
: siteConfig.socialFooter.showOnPages) && <SocialFooter />} : siteConfig.socialFooter.showOnPages) && <SocialFooter />}
</article> </article>
{/* Left sidebar - TOC (placed after article in DOM for SEO) */}
{/* CSS order: -1 positions it visually on the left */}
{hasLeftSidebar && (
<aside className="post-sidebar-wrapper post-sidebar-left">
<PageSidebar
headings={headings}
activeId={location.hash.slice(1)}
/>
</aside>
)}
{/* Right sidebar - with optional AI chat support */} {/* Right sidebar - with optional AI chat support */}
{hasRightSidebar && ( {hasRightSidebar && (
<RightSidebar <RightSidebar
@@ -590,16 +724,8 @@ export default function Post({
<div <div
className={`${hasAnySidebar ? "post-content-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-content-right-sidebar-only" : ""}`} className={`${hasAnySidebar ? "post-content-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-content-right-sidebar-only" : ""}`}
> >
{/* Left sidebar - TOC */} {/* Main content - placed first in DOM for SEO (H1 loads before sidebar H3) */}
{hasLeftSidebar && ( {/* CSS order property handles visual positioning (sidebar on left) */}
<aside className="post-sidebar-wrapper post-sidebar-left">
<PageSidebar
headings={headings}
activeId={location.hash.slice(1)}
/>
</aside>
)}
<article <article
className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-article-centered" : ""}`} className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-article-centered" : ""}`}
> >
@@ -784,6 +910,17 @@ export default function Post({
: siteConfig.socialFooter.showOnPosts) && <SocialFooter />} : siteConfig.socialFooter.showOnPosts) && <SocialFooter />}
</article> </article>
{/* Left sidebar - TOC (placed after article in DOM for SEO) */}
{/* CSS order: -1 positions it visually on the left */}
{hasLeftSidebar && (
<aside className="post-sidebar-wrapper post-sidebar-left">
<PageSidebar
headings={headings}
activeId={location.hash.slice(1)}
/>
</aside>
)}
{/* Right sidebar - with optional AI chat support */} {/* Right sidebar - with optional AI chat support */}
{hasRightSidebar && ( {hasRightSidebar && (
<RightSidebar <RightSidebar

View File

@@ -984,6 +984,8 @@ body {
} }
/* Sidebar layout for pages - two-column grid with docs-style sidebar */ /* Sidebar layout for pages - two-column grid with docs-style sidebar */
/* DOM order: article first (for SEO), sidebar second */
/* Visual order: sidebar on left (order: -1), article on right (order: 0) */
.post-content-with-sidebar { .post-content-with-sidebar {
display: grid; display: grid;
grid-template-columns: 240px 1fr; grid-template-columns: 240px 1fr;
@@ -992,6 +994,17 @@ body {
width: 100%; width: 100%;
} }
/* Article comes first in DOM (order: 0) but displays second visually */
.post-content-with-sidebar .post-article,
.post-content-with-sidebar .post-article-with-sidebar {
order: 0;
}
/* Left sidebar displays first visually (left) but comes second in DOM */
.post-content-with-sidebar .post-sidebar-wrapper.post-sidebar-left {
order: -1;
}
/* Three-column layout when right sidebar is enabled */ /* Three-column layout when right sidebar is enabled */
@media (min-width: 1135px) { @media (min-width: 1135px) {
.post-content-with-sidebar:has(.post-sidebar-right) { .post-content-with-sidebar:has(.post-sidebar-right) {
@@ -1416,6 +1429,22 @@ body {
position: relative; position: relative;
} }
/* Demoted H1 (from markdown) - styled identically to .blog-h1 for visual consistency */
/* This is an H2 element with H1 visual styling, used for SEO (single H1 per page) */
.blog-h1-demoted {
font-size: var(--font-size-blog-h1);
font-weight: 300;
margin: 48px 0 24px;
letter-spacing: -0.02em;
line-height: 1.3;
position: relative;
}
/* Anchor link hover for demoted H1 */
.blog-h1-demoted:hover .heading-anchor {
opacity: 1;
}
.blog-h2 { .blog-h2 {
font-size: var(--font-size-blog-h2); font-size: var(--font-size-blog-h2);
font-weight: 300; font-weight: 300;