feat: raw markdown URLs, author display, GitHub Stars, and frontmatter docs

v1.18.1 - CopyPageDropdown raw markdown URLs
- AI services (ChatGPT, Claude, Perplexity) now receive /raw/{slug}.md URLs
- Direct access to clean markdown content for better AI parsing
- No HTML parsing required by AI services
- Renamed buildUrlFromPageUrl to buildUrlFromRawMarkdown

v1.19.0 - Author display for posts and pages
- New optional authorName and authorImage frontmatter fields
- Round avatar image displayed next to date and read time
- Works on individual post and page views
- Write page updated with new field reference

v1.19.1 - GitHub Stars on Stats page
- Live star count from waynesutton/markdown-site repository
- Fetches from GitHub public API (no token required)
- Stats page now displays 6 cards with responsive grid

Documentation updates
- Frontmatter Flow section added to docs.md, setup-guide.md, files.md
- How frontmatter works with step-by-step processing flow
- Instructions for adding new frontmatter fields

Updated files:
- src/components/CopyPageDropdown.tsx
- src/pages/Stats.tsx
- src/pages/Post.tsx
- src/pages/Write.tsx
- src/styles/global.css
- convex/schema.ts
- convex/posts.ts
- convex/pages.ts
- scripts/sync-posts.ts
- content/blog/setup-guide.md
- content/pages/docs.md
- content/pages/changelog-page.md
- files.md
- README.md
- TASK.md
- changelog.md
- AGENTS.md
This commit is contained in:
Wayne Sutton
2025-12-21 13:59:50 -08:00
parent 29691ee655
commit dd934390cc
36 changed files with 913 additions and 178 deletions

View File

@@ -1,5 +1,14 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Copy, MessageSquare, Sparkles, Search, Check, AlertCircle, FileText, Download } from "lucide-react";
import {
Copy,
MessageSquare,
Sparkles,
Search,
Check,
AlertCircle,
FileText,
Download,
} from "lucide-react";
// Maximum URL length for query parameters (conservative limit)
const MAX_URL_LENGTH = 6000;
@@ -14,9 +23,11 @@ interface AIService {
supportsUrlPrefill: boolean;
// Custom URL builder for services with special formats
buildUrl?: (prompt: string) => string;
// URL-based builder - takes raw markdown file URL for better AI parsing
buildUrlFromRawMarkdown?: (rawMarkdownUrl: string) => string;
}
// All services send the full markdown content directly
// AI services configuration - uses raw markdown URLs for better AI parsing
const AI_SERVICES: AIService[] = [
{
id: "chatgpt",
@@ -25,17 +36,27 @@ const AI_SERVICES: AIService[] = [
baseUrl: "https://chatgpt.com/",
description: "Analyze with ChatGPT",
supportsUrlPrefill: true,
// ChatGPT accepts ?q= with full text content
buildUrl: (prompt) => `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`,
// Uses raw markdown file URL for direct content access
buildUrlFromRawMarkdown: (rawMarkdownUrl) => {
const prompt =
`Summarize the page and then ask what the user needs help with. Be concise and to the point.\n\n` +
`Here is the raw markdown file URL:\n${rawMarkdownUrl}`;
return `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`;
},
},
{
id: "claude",
name: "Claude",
icon: Sparkles,
baseUrl: "https://claude.ai/new",
baseUrl: "https://claude.ai/",
description: "Analyze with Claude",
supportsUrlPrefill: true,
buildUrl: (prompt) => `https://claude.ai/new?q=${encodeURIComponent(prompt)}`,
buildUrlFromRawMarkdown: (rawMarkdownUrl) => {
const prompt =
`Summarize the page and then ask what the user needs help with. Be concise and to the point.\n\n` +
`Here is the raw markdown file URL:\n${rawMarkdownUrl}`;
return `https://claude.ai/new?q=${encodeURIComponent(prompt)}`;
},
},
{
id: "perplexity",
@@ -44,7 +65,12 @@ const AI_SERVICES: AIService[] = [
baseUrl: "https://www.perplexity.ai/search",
description: "Research with Perplexity",
supportsUrlPrefill: true,
buildUrl: (prompt) => `https://www.perplexity.ai/search?q=${encodeURIComponent(prompt)}`,
buildUrlFromRawMarkdown: (rawMarkdownUrl) => {
const prompt =
`Summarize the page and then ask what the user needs help with. Be concise and to the point.\n\n` +
`Here is the raw markdown file URL:\n${rawMarkdownUrl}`;
return `https://www.perplexity.ai/search?q=${encodeURIComponent(prompt)}`;
},
},
];
@@ -63,28 +89,28 @@ interface CopyPageDropdownProps {
// Enhanced markdown format for better LLM parsing
function formatAsMarkdown(props: CopyPageDropdownProps): string {
const { title, content, url, description, date, tags, readTime } = props;
// Build metadata section
const metadataLines: string[] = [];
metadataLines.push(`Source: ${url}`);
if (date) metadataLines.push(`Date: ${date}`);
if (readTime) metadataLines.push(`Reading time: ${readTime}`);
if (tags && tags.length > 0) metadataLines.push(`Tags: ${tags.join(", ")}`);
// Build the full markdown document
let markdown = `# ${title}\n\n`;
// Add description if available
if (description) {
markdown += `> ${description}\n\n`;
}
// Add metadata block
markdown += `---\n${metadataLines.join("\n")}\n---\n\n`;
// Add main content
markdown += content;
return markdown;
}
@@ -102,45 +128,45 @@ function generateSkillName(slug: string): string {
// Follows: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview
function formatAsSkill(props: CopyPageDropdownProps): string {
const { title, content, slug, description, tags } = props;
// Generate compliant skill name
const skillName = generateSkillName(slug);
// Build description with "when to use" triggers (max 1024 chars)
const tagList = tags && tags.length > 0 ? tags.join(", ") : "";
let skillDescription = description || `Guide about ${title.toLowerCase()}.`;
// Add usage triggers to description
if (tagList) {
skillDescription += ` Use when working with ${tagList.toLowerCase()} or when asked about ${title.toLowerCase()}.`;
} else {
skillDescription += ` Use when asked about ${title.toLowerCase()}.`;
}
// Truncate description if needed (max 1024 chars)
if (skillDescription.length > 1024) {
skillDescription = skillDescription.slice(0, 1021) + "...";
}
// Build YAML frontmatter (required by Agent Skills spec)
let skill = `---\n`;
skill += `name: ${skillName}\n`;
skill += `description: ${skillDescription}\n`;
skill += `---\n\n`;
// Add title
skill += `# ${title}\n\n`;
// Add instructions section
skill += `## Instructions\n\n`;
skill += content;
// Add examples section placeholder if content doesn't include examples
if (!content.toLowerCase().includes("## example")) {
skill += `\n\n## Examples\n\n`;
skill += `Use this skill when the user asks about topics covered in this guide.\n`;
}
return skill;
}
@@ -154,7 +180,7 @@ type FeedbackState = "idle" | "copied" | "error" | "url-too-long";
export default function CopyPageDropdown(props: CopyPageDropdownProps) {
const { title } = props;
const [isOpen, setIsOpen] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState>("idle");
const [feedbackMessage, setFeedbackMessage] = useState("");
@@ -195,7 +221,7 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
const items = menu.querySelectorAll<HTMLButtonElement>(".copy-page-item");
const currentIndex = Array.from(items).findIndex(
(item) => item === document.activeElement
(item) => item === document.activeElement,
);
switch (event.key) {
@@ -276,7 +302,7 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
const handleCopyPage = async () => {
const markdown = formatAsMarkdown(props);
const success = await writeToClipboard(markdown);
if (success) {
setFeedback("copied");
setFeedbackMessage("Copied!");
@@ -284,18 +310,30 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
setFeedback("error");
setFeedbackMessage("Failed to copy");
}
clearFeedback();
setTimeout(() => setIsOpen(false), 1500);
};
// Generic handler for opening AI services
// All services receive the full markdown content directly
// Uses raw markdown URL for better AI parsing
// 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
if (service.buildUrlFromRawMarkdown) {
// Build raw markdown URL from page URL and slug
const origin = new URL(props.url).origin;
const rawMarkdownUrl = `${origin}/raw/${props.slug}.md`;
const targetUrl = service.buildUrlFromRawMarkdown(rawMarkdownUrl);
window.open(targetUrl, "_blank");
setIsOpen(false);
return;
}
// Other services: send full markdown content
const markdown = formatAsMarkdown(props);
const prompt = `Please analyze this article:\n\n${markdown}`;
// Build the target URL using the service's buildUrl function
if (!service.buildUrl) {
// Fallback: open base URL FIRST (sync), then copy to clipboard
@@ -311,9 +349,9 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
clearFeedback();
return;
}
const targetUrl = service.buildUrl(prompt);
// Check URL length - if too long, open base URL then copy to clipboard
if (isUrlTooLong(targetUrl)) {
// Open window FIRST (must be sync to avoid popup blocker)
@@ -337,9 +375,11 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
// Handle download skill file (Anthropic Agent Skills format)
const handleDownloadSkill = () => {
const skillContent = formatAsSkill(props);
const blob = new Blob([skillContent], { type: "text/markdown;charset=utf-8" });
const blob = new Blob([skillContent], {
type: "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
// Create temporary link and trigger download as SKILL.md
const link = document.createElement("a");
link.href = url;
@@ -347,10 +387,10 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up object URL
URL.revokeObjectURL(url);
setFeedback("copied");
setFeedbackMessage("Downloaded!");
clearFeedback();
@@ -363,7 +403,9 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
case "copied":
return <Check size={16} className="copy-page-icon feedback-success" />;
case "error":
return <AlertCircle size={16} className="copy-page-icon feedback-error" />;
return (
<AlertCircle size={16} className="copy-page-icon feedback-error" />
);
case "url-too-long":
return <Check size={16} className="copy-page-icon feedback-warning" />;
default:
@@ -447,7 +489,9 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
<div className="copy-page-item-content">
<span className="copy-page-item-title">
Open in {service.name}
<span className="external-arrow" aria-hidden="true"></span>
<span className="external-arrow" aria-hidden="true">
</span>
</span>
<span className="copy-page-item-desc">
{service.description}
@@ -471,11 +515,11 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
<div className="copy-page-item-content">
<span className="copy-page-item-title">
View as Markdown
<span className="external-arrow" aria-hidden="true"></span>
</span>
<span className="copy-page-item-desc">
Open raw .md file
<span className="external-arrow" aria-hidden="true">
</span>
</span>
<span className="copy-page-item-desc">Open raw .md file</span>
</div>
</button>
@@ -488,9 +532,7 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
>
<Download size={16} className="copy-page-icon" aria-hidden="true" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">
Download as SKILL.md
</span>
<span className="copy-page-item-title">Download as SKILL.md</span>
<span className="copy-page-item-desc">
Anthropic Agent Skills format
</span>

View File

@@ -72,7 +72,7 @@ export const siteConfig: SiteConfig = {
// Optional logo/header image (place in public/images/, set to null to hide)
logo: "/images/logo.svg",
intro: null, // Set in Home.tsx to allow JSX with links
bio: `Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents.`,
bio: `Your content is instantly available to browsers, LLMs, and AI agents.`,
// Featured section configuration
// viewMode: 'list' shows bullet list, 'cards' shows card grid with excerpts

View File

@@ -97,16 +97,8 @@ export default function Home() {
<strong>
An open-source publishing framework for AI agents and developers.
</strong>{" "}
Write markdown, sync from the terminal.{" "}
<a
href="https://github.com/waynesutton/markdown-site"
target="_blank"
rel="noopener noreferrer"
className="home-text-link"
>
Fork it
</a>
, customize it, ship it.
<br />
Write markdown, sync from the terminal.
</p>
<p className="home-bio">{siteConfig.bio}</p>

View File

@@ -163,6 +163,23 @@ export default function Post() {
<article className="post-article">
<header className="post-header">
<h1 className="post-title">{page.title}</h1>
{/* Author avatar and name for pages (optional) */}
{(page.authorImage || page.authorName) && (
<div className="post-meta-header">
<div className="post-author">
{page.authorImage && (
<img
src={page.authorImage}
alt={page.authorName || "Author"}
className="post-author-image"
/>
)}
{page.authorName && (
<span className="post-author-name">{page.authorName}</span>
)}
</div>
</div>
)}
</header>
<BlogPost content={page.content} />
@@ -228,6 +245,22 @@ export default function Post() {
<header className="post-header">
<h1 className="post-title">{post.title}</h1>
<div className="post-meta-header">
{/* Author avatar and name (optional) */}
{(post.authorImage || post.authorName) && (
<div className="post-author">
{post.authorImage && (
<img
src={post.authorImage}
alt={post.authorName || "Author"}
className="post-author-image"
/>
)}
{post.authorName && (
<span className="post-author-name">{post.authorName}</span>
)}
<span className="post-meta-separator">·</span>
</div>
)}
<time className="post-date">
{format(parseISO(post.date), "MMMM yyyy")}
</time>

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from "react";
import { useQuery } from "convex/react";
import { useNavigate } from "react-router-dom";
import { api } from "../../convex/_generated/api";
@@ -8,9 +9,8 @@ import {
FileText,
BookOpen,
Activity,
TrendingUp,
Code,
} from "lucide-react";
import { GithubLogo } from "@phosphor-icons/react";
// Site launched Dec 14, 2025 at 1:00 PM (v1.0.0), stats added same day (v1.2.0)
const SITE_LAUNCH_DATE = "Dec 14, 2025 at 1:00 PM";
@@ -35,6 +35,17 @@ export default function Stats() {
const navigate = useNavigate();
const stats = useQuery(api.stats.getStats);
// GitHub stars state
const [githubStars, setGithubStars] = useState<number | null>(null);
// Fetch GitHub stars on mount
useEffect(() => {
fetch("https://api.github.com/repos/waynesutton/markdown-site")
.then((res) => res.json())
.then((data) => setGithubStars(data.stargazers_count))
.catch(() => setGithubStars(null));
}, []);
// Don't render until stats load
if (stats === undefined) {
return null;
@@ -78,6 +89,13 @@ export default function Stats() {
value: stats.publishedPages,
description: "Static pages",
},
{
number: "06",
icon: GithubLogo,
title: "GitHub Stars",
value: githubStars ?? "...",
description: "waynesutton/markdown-site",
},
];
return (

View File

@@ -35,6 +35,8 @@ const POST_FIELDS = [
},
{ name: "featured", required: false, example: "true" },
{ name: "featuredOrder", required: false, example: "1" },
{ name: "authorName", required: false, example: '"Jane Doe"' },
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
];
// Frontmatter field definitions for pages
@@ -47,6 +49,8 @@ const PAGE_FIELDS = [
{ name: "image", required: false, example: '"/images/thumbnail.png"' },
{ name: "featured", required: false, example: "true" },
{ name: "featuredOrder", required: false, example: "1" },
{ name: "authorName", required: false, example: '"Jane Doe"' },
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
];
// Generate frontmatter template based on content type

View File

@@ -750,6 +750,27 @@ body {
color: var(--text-muted);
}
/* Author display in post header */
.post-author {
display: flex;
align-items: center;
gap: 8px;
}
.post-author-image {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--border-color);
}
.post-author-name {
font-size: var(--font-size-post-meta-header);
color: var(--text-secondary);
font-weight: 500;
}
.post-description {
font-size: var(--font-size-post-description);
color: var(--text-secondary);
@@ -1462,7 +1483,7 @@ body {
/* Modern horizontal cards container - 5 equal columns on large screens */
.stats-cards-modern {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
gap: 0;
margin-bottom: 64px;
border: 1px solid var(--border-color);
@@ -1471,10 +1492,10 @@ body {
background: var(--bg-secondary);
}
/* Ensure 5-column layout on large screens (explicit rule) */
/* Ensure 6-column layout on large screens (explicit rule) */
@media (min-width: 1101px) {
.stats-cards-modern {
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
}
}
@@ -1707,18 +1728,26 @@ body {
min-height: 160px;
}
.stat-card-modern:nth-child(3) {
/* Remove right border on last card of each row (3rd, 6th) */
.stat-card-modern:nth-child(3),
.stat-card-modern:nth-child(6) {
border-right: none;
}
/* Add top border on second row (4th, 5th, 6th) */
.stat-card-modern:nth-child(4),
.stat-card-modern:nth-child(5) {
.stat-card-modern:nth-child(5),
.stat-card-modern:nth-child(6) {
border-top: 1px solid var(--border-color);
}
.stat-card-modern:nth-child(4) {
border-right: 1px solid var(--border-color);
}
.stat-card-modern:nth-child(5) {
border-right: 1px solid var(--border-color);
}
}
@media (max-width: 768px) {
@@ -1735,19 +1764,23 @@ body {
min-height: 150px;
}
/* Remove right border on even cards (end of each row) */
.stat-card-modern:nth-child(2n) {
border-right: none;
}
/* Add top border on rows 2 and 3 (cards 3-6) */
.stat-card-modern:nth-child(3),
.stat-card-modern:nth-child(4),
.stat-card-modern:nth-child(5) {
.stat-card-modern:nth-child(5),
.stat-card-modern:nth-child(6) {
border-top: 1px solid var(--border-color);
border-right: 1px solid var(--border-color);
}
.stat-card-modern:nth-child(4) {
border-right: none;
/* Odd cards in rows 2+ need right border */
.stat-card-modern:nth-child(3),
.stat-card-modern:nth-child(5) {
border-right: 1px solid var(--border-color);
}
.stat-card-modern-value {