feat(api): add /api/raw/:slug endpoint for AI tools (ChatGPT, Claude, Perplexity)

- Create netlify/functions/raw.ts Netlify Function
- Returns text/plain with minimal headers for reliable AI ingestion
- Reads from dist/raw/ (production) or public/raw/ (dev/preview)
- Update CopyPageDropdown to use /api/raw/:slug for AI services
- Keep /raw/:slug.md for View as Markdown browser viewing
- Add @netlify/functions dev dependency
This commit is contained in:
Wayne Sutton
2025-12-24 00:35:02 -08:00
parent 6ac6098668
commit c98049c411
6 changed files with 147 additions and 18 deletions

View File

@@ -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

View File

@@ -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/*"

106
netlify/functions/raw.ts Normal file
View File

@@ -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 };

View File

@@ -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",

View File

@@ -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);

View File

@@ -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,