diff --git a/changelog.md b/changelog.md index b653475..c4b999f 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,30 @@ 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/). +## [1.24.9] - 2025-12-24 + +### Added + +- Safety-net raw markdown endpoint for AI tools (`/api/raw/:slug`) + - New Netlify Function at `netlify/functions/raw.ts` + - Returns `text/plain` with minimal headers for reliable AI ingestion + - Reads from `dist/raw/` (production) or `public/raw/` (dev/preview) + - Handles 400 (missing slug), 404 (not found), and 200 (success) responses + - No Link, X-Robots-Tag, or SEO headers that cause AI fetch failures + +### Changed + +- AI service links (ChatGPT, Claude, Perplexity) now use `/api/raw/:slug` instead of `/raw/:slug.md` + - Netlify Function endpoint more reliable for AI crawler fetch + - "View as Markdown" menu item still uses `/raw/:slug.md` for browser viewing + +### Technical + +- `netlify/functions/raw.ts`: New Netlify Function to serve raw markdown +- `netlify.toml`: Added redirect from `/api/raw/*` to the function +- `src/components/CopyPageDropdown.tsx`: AI services use `/api/raw/:slug` endpoint +- `package.json`: Added `@netlify/functions` dev dependency + ## [1.24.8] - 2025-12-23 ### Fixed diff --git a/netlify.toml b/netlify.toml index 5fe7ede..c80a5ec 100644 --- a/netlify.toml +++ b/netlify.toml @@ -5,6 +5,13 @@ [build.environment] NODE_VERSION = "20" +# API raw markdown endpoint for AI tools (ChatGPT, Claude, Perplexity) +[[redirects]] + from = "/api/raw/*" + to = "/.netlify/functions/raw/:splat" + status = 200 + force = true + # Raw markdown passthrough - explicit rule prevents SPA fallback from intercepting [[redirects]] from = "/raw/*" diff --git a/netlify/functions/raw.ts b/netlify/functions/raw.ts new file mode 100644 index 0000000..f9b651a --- /dev/null +++ b/netlify/functions/raw.ts @@ -0,0 +1,106 @@ +import type { Handler, HandlerEvent, HandlerContext } from "@netlify/functions"; +import * as fs from "fs"; +import * as path from "path"; + +/** + * Netlify Function: /api/raw/:slug + * + * Serves raw markdown files for AI tools (ChatGPT, Claude, Perplexity). + * Returns text/plain with minimal headers for reliable AI ingestion. + */ + +// Response headers optimized for AI crawlers +const AI_HEADERS = { + "Content-Type": "text/plain; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", + // No Link, X-Robots-Tag, or SEO headers +}; + +// Extract slug from path like /api/raw/my-post or /.netlify/functions/raw/my-post +function extractSlug(rawPath: string): string | null { + // Handle both /api/raw/:slug and /.netlify/functions/raw/:slug patterns + const patterns = [ + /^\/api\/raw\/(.+)$/, + /^\/.netlify\/functions\/raw\/(.+)$/, + ]; + + for (const pattern of patterns) { + const match = rawPath.match(pattern); + if (match && match[1]) { + // Remove .md extension if present + return match[1].replace(/\.md$/, ""); + } + } + + return null; +} + +// Try to read markdown file from multiple locations +function readMarkdownFile(slug: string): string | null { + // Possible file locations (in order of priority) + const locations = [ + // Production: built output + path.join(process.cwd(), "dist", "raw", `${slug}.md`), + // Dev/Preview: source files + path.join(process.cwd(), "public", "raw", `${slug}.md`), + ]; + + for (const filePath of locations) { + try { + if (fs.existsSync(filePath)) { + return fs.readFileSync(filePath, "utf-8"); + } + } catch { + // Continue to next location + } + } + + return null; +} + +const handler: Handler = async ( + event: HandlerEvent, + _context: HandlerContext, +) => { + // Only allow GET requests + if (event.httpMethod !== "GET") { + return { + statusCode: 405, + headers: AI_HEADERS, + body: "Method not allowed. Use GET.", + }; + } + + // Extract slug from path + const slug = extractSlug(event.path); + + if (!slug) { + return { + statusCode: 400, + headers: AI_HEADERS, + body: "Bad request. Usage: /api/raw/{slug}", + }; + } + + // Try to read the markdown file + const content = readMarkdownFile(slug); + + if (!content) { + return { + statusCode: 404, + headers: AI_HEADERS, + body: `Not found: ${slug}.md`, + }; + } + + // Return the raw markdown content + return { + statusCode: 200, + headers: AI_HEADERS, + body: content, + }; +}; + +export { handler }; + diff --git a/package.json b/package.json index 0b903de..cbb0d69 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "remark-gfm": "^4.0.0" }, "devDependencies": { + "@netlify/functions": "^2.8.2", "@types/node": "^25.0.2", "@types/react": "^18.2.56", "@types/react-dom": "^18.2.19", diff --git a/src/components/CopyPageDropdown.tsx b/src/components/CopyPageDropdown.tsx index 60ad106..a762d1e 100644 --- a/src/components/CopyPageDropdown.tsx +++ b/src/components/CopyPageDropdown.tsx @@ -322,27 +322,18 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) { }; // Generic handler for opening AI services - // Uses raw markdown URL for better AI parsing + // Uses /api/raw/:slug endpoint for AI tools (ChatGPT, Claude, Perplexity) // IMPORTANT: window.open must happen BEFORE any await to avoid popup blockers const handleOpenInAI = async (service: AIService) => { - // Use raw markdown URL for better AI parsing + // Use /api/raw/:slug endpoint for AI tools - more reliable than static /raw/*.md files if (service.buildUrlFromRawMarkdown) { - // Build absolute raw markdown URL using current origin (not props.url) - // This ensures correct URL even if props.url points to canonical/deploy preview domain - const rawMarkdownUrl = new URL( - `/raw/${props.slug}.md`, + // Build absolute API URL using current origin + // Uses Netlify Function endpoint that returns text/plain with minimal headers + const apiRawUrl = new URL( + `/api/raw/${props.slug}`, window.location.origin, ).toString(); - const targetUrl = service.buildUrlFromRawMarkdown(rawMarkdownUrl); - - // For ChatGPT, add fallback if URL fetch fails - if (service.id === "chatgpt") { - // Open ChatGPT with raw URL - window.open(targetUrl, "_blank"); - setIsOpen(false); - // Optional: Could add error handling here if needed - return; - } + const targetUrl = service.buildUrlFromRawMarkdown(apiRawUrl); window.open(targetUrl, "_blank"); setIsOpen(false); diff --git a/src/styles/global.css b/src/styles/global.css index 9c87b07..a777443 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -116,7 +116,7 @@ --font-size-mobile-nav-link: var(--font-size-base); --font-size-mobile-home-link: var(--font-size-md); --font-size-mobile-toc-title: var(--font-size-2xs); - --font-size-mobile-toc-link: var(--font-size-sm); + --font-size-mobile-toc-link: var(--font-size-md); /* Copy dropdown font sizes */ --font-size-copy-trigger: var(--font-size-sm); @@ -3811,7 +3811,7 @@ body { } .mobile-menu-toc-level-3 { padding-left: 28px; - font-size: var(--font-size-xs); + font-size: var(--font-size-sm); } .mobile-menu-toc-level-4, .mobile-menu-toc-level-5,