fix: fork configuration now updates 14 files, logoGallery uses relative URLs and Search result highlighting and scroll-to-match

This commit is contained in:
Wayne Sutton
2026-01-04 09:24:08 -08:00
parent ca40d199da
commit 0baedee682
22 changed files with 1254 additions and 97 deletions

View File

@@ -22,7 +22,7 @@ Your content is instantly available to browsers, LLMs, and AI agents.. Write mar
- **Total Posts**: 17
- **Total Pages**: 4
- **Latest Post**: 2025-12-29
- **Last Updated**: 2026-01-04T04:52:17.079Z
- **Last Updated**: 2026-01-04T05:50:22.819Z
## Tech stack

View File

@@ -5,7 +5,7 @@ Project instructions for Claude Code.
## Project context
<!-- Auto-updated by sync:discovery -->
<!-- Site: markdown | Posts: 17 | Pages: 4 | Updated: 2026-01-04T04:52:17.080Z -->
<!-- Site: markdown | Posts: 17 | Pages: 4 | Updated: 2026-01-04T05:50:22.820Z -->
Markdown sync framework. Write markdown in `content/`, run sync commands, content appears instantly via Convex real-time database. Built for developers and AI agents.

View File

@@ -49,20 +49,21 @@ The file `fork-config.json` is gitignored, so your configuration stays local and
npm run configure
```
This updates all 11 configuration files automatically:
This updates all 14 configuration files automatically:
- `src/config/siteConfig.ts`
- `src/pages/Home.tsx`
- `src/pages/Post.tsx`
- `convex/http.ts`
- `convex/rss.ts`
- `index.html`
- `public/llms.txt`
- `public/robots.txt`
- `public/openapi.yaml`
- `public/.well-known/ai-plugin.json`
Theme is now configured in `src/config/siteConfig.ts` (via the `defaultTheme` field).
- `src/config/siteConfig.ts` (site name, bio, GitHub username, gitHubRepo config, default theme)
- `src/pages/Home.tsx` (intro paragraph, footer links)
- `src/pages/Post.tsx` (SITE_URL, SITE_NAME constants)
- `src/pages/DocsPage.tsx` (SITE_URL constant for CopyPageDropdown)
- `convex/http.ts` (SITE_URL, SITE_NAME constants)
- `convex/rss.ts` (SITE_URL, SITE_TITLE, SITE_DESCRIPTION)
- `netlify/edge-functions/mcp.ts` (SITE_URL, SITE_NAME, MCP_SERVER_NAME)
- `scripts/send-newsletter.ts` (default SITE_URL)
- `index.html` (meta tags, JSON-LD, page title)
- `public/llms.txt` (site info, GitHub link)
- `public/robots.txt` (sitemap URL)
- `public/openapi.yaml` (server URL, site name, example URLs)
- `public/.well-known/ai-plugin.json` (plugin metadata)
### Step 4: Review and deploy
@@ -83,17 +84,19 @@ Edit each file individually following the guide below.
| File | What to Update |
| ----------------------------------- | ------------------------------------------------------------ |
| `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, features |
| `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, default theme, features |
| `src/pages/Home.tsx` | Intro paragraph, footer links |
| `src/pages/Post.tsx` | `SITE_URL`, `SITE_NAME` constants |
| `src/pages/DocsPage.tsx` | `SITE_URL` constant |
| `convex/http.ts` | `SITE_URL`, `SITE_NAME` constants |
| `convex/rss.ts` | `SITE_URL`, `SITE_TITLE`, `SITE_DESCRIPTION` |
| `netlify/edge-functions/mcp.ts` | `SITE_URL`, `SITE_NAME`, `MCP_SERVER_NAME` constants |
| `scripts/send-newsletter.ts` | Default `SITE_URL` constant |
| `index.html` | Meta tags, JSON-LD, page title |
| `public/llms.txt` | Site info, GitHub link |
| `public/robots.txt` | Sitemap URL |
| `public/openapi.yaml` | Server URL, site name |
| `public/openapi.yaml` | Server URL, site name, example URLs |
| `public/.well-known/ai-plugin.json` | Plugin metadata |
| `src/config/siteConfig.ts` | Default theme (`defaultTheme` field) |
---
@@ -1234,16 +1237,19 @@ GitHub Repo Config (for AI service links):
- Content Path: public/raw
Update these files:
1. src/config/siteConfig.ts - site name, bio, GitHub username, gitHubRepo config
1. src/config/siteConfig.ts - site name, bio, GitHub username, gitHubRepo config, defaultTheme
2. src/pages/Home.tsx - intro paragraph and footer section with all creator links
3. src/pages/Post.tsx - SITE_URL and SITE_NAME constants
4. convex/http.ts - SITE_URL and SITE_NAME constants
5. convex/rss.ts - SITE_URL, SITE_TITLE, SITE_DESCRIPTION
6. index.html - all meta tags, JSON-LD, title
7. public/llms.txt - site info and GitHub link
8. public/robots.txt - header comment and sitemap URL
9. public/openapi.yaml - API title, server URL, contact URL
10. public/.well-known/ai-plugin.json - plugin metadata and contact email
4. src/pages/DocsPage.tsx - SITE_URL constant
5. convex/http.ts - SITE_URL and SITE_NAME constants
6. convex/rss.ts - SITE_URL, SITE_TITLE, SITE_DESCRIPTION
7. netlify/edge-functions/mcp.ts - SITE_URL, SITE_NAME, MCP_SERVER_NAME constants
8. scripts/send-newsletter.ts - default SITE_URL constant
9. index.html - all meta tags, JSON-LD, title
10. public/llms.txt - site info and GitHub link
11. public/robots.txt - header comment and sitemap URL
12. public/openapi.yaml - API title, server URL, contact URL, example URLs
13. public/.well-known/ai-plugin.json - plugin metadata and contact email
```
---

19
TASK.md
View File

@@ -4,10 +4,27 @@
## Current Status
v2.8.4 ready. AI service links now use local /raw URLs with simplified prompt.
v2.8.6 ready. Fork configuration script now updates 14 files for complete site branding.
## Completed
- [x] Fork configuration improvements
- [x] Updated `scripts/configure-fork.ts` to update 3 additional files (DocsPage.tsx, mcp.ts, send-newsletter.ts)
- [x] Improved `updateOpenApiYaml()` to handle all example URLs in OpenAPI spec
- [x] Changed logoGallery hrefs from hardcoded markdown.fast URLs to relative URLs
- [x] Updated `FORK_CONFIG.md` with complete file list (14 files, was 11)
- [x] Updated `content/blog/fork-configuration-guide.md` with accurate file count
- [x] Added missing options to `fork-config.json.example` (statsPage, mcpServer, imageLightbox)
- [x] Search result highlighting and scroll-to-match
- [x] Created `useSearchHighlighting.ts` hook with polling mechanism to wait for content load
- [x] Search query passed via `?q=` URL parameter for highlighting on destination page
- [x] All matching text highlighted with theme-appropriate colors (dark/light/tan/cloud)
- [x] First match scrolls into view centered in viewport with header offset
- [x] Highlights pulse on arrival, fade to subtle after 4 seconds
- [x] Press Escape to clear highlights
- [x] Updated SearchModal.tsx, BlogPost.tsx, Post.tsx, global.css
- [x] Update AI service links to use local /raw URLs
- [x] Changed ChatGPT, Claude, Perplexity links from GitHub raw URLs to `/raw/{slug}.md`
- [x] Simplified AI prompt to "Read this URL and summarize it:"

View File

@@ -4,6 +4,44 @@ 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/).
## [2.8.6] - 2026-01-04
### Changed
- Fork configuration script now updates 14 files (was 11)
- Added `src/pages/DocsPage.tsx` (SITE_URL constant)
- Added `netlify/edge-functions/mcp.ts` (SITE_URL, SITE_NAME, MCP_SERVER_NAME)
- Added `scripts/send-newsletter.ts` (default SITE_URL)
- Improved `public/openapi.yaml` handling for all example URLs
- Logo gallery hrefs now use relative URLs instead of hardcoded markdown.fast URLs
- Links like `/how-to-use-firecrawl`, `/docs`, `/setup-guide` work on any forked site
- Updated `fork-config.json.example` with missing options (statsPage, mcpServer, imageLightbox)
### Technical
- Updated `scripts/configure-fork.ts` with new update functions: `updateDocsPageTsx()`, `updateMcpEdgeFunction()`, `updateSendNewsletter()`
- Updated `FORK_CONFIG.md` with complete file list and updated AI agent prompt
- Updated `content/blog/fork-configuration-guide.md` with accurate file count and output example
## [2.8.5] - 2026-01-03
### Added
- Search result highlighting and scroll-to-match feature
- Clicking a search result navigates to the exact match location (not just the heading)
- All matching text is highlighted with theme-appropriate colors
- Highlights pulse on arrival, then fade to subtle background after 4 seconds
- Press Escape to clear highlights
- Works across all four themes (dark, light, tan, cloud)
### Technical
- Created `src/hooks/useSearchHighlighting.ts` hook with polling mechanism to wait for content load
- Updated `src/components/SearchModal.tsx` to pass search query via `?q=` URL parameter
- Updated `src/components/BlogPost.tsx` with article ref for highlighting
- Updated `src/pages/Post.tsx` to defer scroll handling to highlighting hook when `?q=` present
- Added `.search-highlight` and `.search-highlight-active` CSS styles with theme-specific colors
## [2.8.4] - 2026-01-03
### Changed

View File

@@ -68,7 +68,7 @@ Open `fork-config.json` and update the values:
npm run configure
```
The script reads your JSON file and updates all 11 configuration files automatically. You should see output like:
The script reads your JSON file and updates all 14 configuration files automatically. You should see output like:
```
Fork Configuration Script
@@ -77,14 +77,16 @@ Reading config from fork-config.json...
Updating src/config/siteConfig.ts...
Updating src/pages/Home.tsx...
Updating src/pages/Post.tsx...
Updating src/pages/DocsPage.tsx...
Updating convex/http.ts...
Updating convex/rss.ts...
Updating netlify/edge-functions/mcp.ts...
Updating scripts/send-newsletter.ts...
Updating index.html...
Updating public/llms.txt...
Updating public/robots.txt...
Updating public/openapi.yaml...
Updating public/.well-known/ai-plugin.json...
Updating default theme in src/config/siteConfig.ts...
Configuration complete!
```
@@ -103,17 +105,19 @@ The configuration script updates these files:
| File | What changes |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, features (logo gallery, GitHub contributions, visitor map, blog page, posts display, homepage, right sidebar, footer, social footer, AI chat, newsletter, contact form, newsletter admin, stats page, MCP server, dashboard, image lightbox) |
| `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, default theme, features (logo gallery, GitHub contributions, visitor map, blog page, posts display, homepage, right sidebar, footer, social footer, AI chat, newsletter, contact form, newsletter admin, stats page, MCP server, dashboard, image lightbox) |
| `src/pages/Home.tsx` | Intro paragraph, footer links |
| `src/pages/Post.tsx` | SITE_URL, SITE_NAME constants |
| `src/pages/DocsPage.tsx` | SITE_URL constant |
| `convex/http.ts` | SITE_URL, SITE_NAME constants |
| `convex/rss.ts` | SITE_URL, SITE_TITLE, SITE_DESCRIPTION |
| `netlify/edge-functions/mcp.ts` | SITE_URL, SITE_NAME, MCP_SERVER_NAME constants |
| `scripts/send-newsletter.ts` | Default SITE_URL constant |
| `index.html` | Meta tags, JSON-LD, page title |
| `public/llms.txt` | Site info, GitHub link |
| `public/robots.txt` | Sitemap URL |
| `public/openapi.yaml` | Server URL, site name |
| `public/openapi.yaml` | Server URL, site name, example URLs |
| `public/.well-known/ai-plugin.json` | Plugin metadata |
| `src/config/siteConfig.ts` | Default theme (`defaultTheme` field) |
## Optional settings

View File

@@ -10,7 +10,42 @@ docsSectionOrder: 4
---
All notable changes to this project.
![](https://img.shields.io/badge/License-MIT-yellow.svg)
## v2.8.6
Released January 4, 2026
**Fork configuration improvements**
- Fork configuration script now updates 14 files (was 11)
- Added `src/pages/DocsPage.tsx` (SITE_URL constant)
- Added `netlify/edge-functions/mcp.ts` (SITE_URL, SITE_NAME, MCP_SERVER_NAME)
- Added `scripts/send-newsletter.ts` (default SITE_URL)
- Improved `public/openapi.yaml` handling for all example URLs
- Logo gallery hrefs now use relative URLs instead of hardcoded markdown.fast URLs
- Updated `fork-config.json.example` with missing options (statsPage, mcpServer, imageLightbox)
Updated files: `scripts/configure-fork.ts`, `src/config/siteConfig.ts`, `FORK_CONFIG.md`, `content/blog/fork-configuration-guide.md`, `fork-config.json.example`
## v2.8.5
Released January 3, 2026
**Search result highlighting and scroll-to-match**
- Clicking a search result now navigates to the exact match location (not just the nearest heading)
- All matching text is highlighted with theme-appropriate colors
- Highlights pulse on arrival, then fade to subtle background after 4 seconds
- Press Escape to clear highlights
- Works across all four themes (dark, light, tan, cloud)
**Technical details:**
- New `useSearchHighlighting.ts` hook with polling mechanism to wait for content load
- Search query passed via `?q=` URL parameter instead of hash anchor
- Theme-specific highlight colors matching existing design system
Updated files: `src/hooks/useSearchHighlighting.ts` (new), `src/components/SearchModal.tsx`, `src/components/BlogPost.tsx`, `src/pages/Post.tsx`, `src/styles/global.css`
## v2.8.4

View File

@@ -94,9 +94,10 @@ A brief description of each file in the codebase.
### Hooks (`src/hooks/`)
| File | Description |
| -------------------- | ------------------------------------------------ |
| `usePageTracking.ts` | Page view recording and active session heartbeat |
| File | Description |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `usePageTracking.ts` | Page view recording and active session heartbeat |
| `useSearchHighlighting.ts` | Search term highlighting and scroll-to-match. Reads `?q=` URL param, waits for content to load, highlights matches in DOM, scrolls to first match. |
### Styles (`src/styles/`)
@@ -225,7 +226,7 @@ Markdown files for static pages like About, Projects, Contact, Changelog.
| `sync-posts.ts` | Syncs markdown files to Convex at build time (markdown sync v2). Generates `raw/index.md` with home.md content at top, posts/pages list, and footer.md content at bottom |
| `sync-discovery-files.ts` | Updates AGENTS.md, CLAUDE.md, and llms.txt with current app data |
| `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) |
| `configure-fork.ts` | Automated fork configuration (reads fork-config.json). ES module compatible using fileURLToPath for __dirname equivalent. |
| `configure-fork.ts` | Automated fork configuration (reads fork-config.json, updates 14 files). ES module compatible using fileURLToPath for __dirname equivalent. |
| `send-newsletter.ts` | CLI tool for sending newsletter posts (npm run newsletter:send <slug>). Calls scheduleSendPostNewsletter mutation directly. |
| `send-newsletter-stats.ts` | CLI tool for sending weekly stats summary (npm run newsletter:send:stats). Calls scheduleSendStatsSummary mutation directly. |
| `sync-server.ts` | Local HTTP server for executing sync commands from Dashboard UI. Runs on localhost:3001 with optional token authentication. Whitelisted commands only. Part of markdown sync v2. |

View File

@@ -162,6 +162,20 @@
"dayOfWeek": 0,
"subject": "Weekly Digest"
},
"statsPage": {
"enabled": true,
"showInNav": true
},
"mcpServer": {
"enabled": true,
"endpoint": "/mcp",
"publicRateLimit": 50,
"authenticatedRateLimit": 1000,
"requireAuth": false
},
"imageLightbox": {
"enabled": true
},
"dashboard": {
"enabled": true,
"requireAuth": false

View File

@@ -1,6 +1,6 @@
# llms.txt - Information for AI assistants and LLMs
# Learn more: https://llmstxt.org/
# Last updated: 2026-01-04T04:52:17.081Z
# Last updated: 2026-01-04T05:50:22.820Z
> Your content is instantly available to browsers, LLMs, and AI agents.

View File

@@ -6,7 +6,43 @@ Date: 2026-01-04
---
All notable changes to this project.
![](https://img.shields.io/badge/License-MIT-yellow.svg)
## v2.8.5
Released January 3, 2026
**Search result highlighting and scroll-to-match**
- Clicking a search result now navigates to the exact match location (not just the nearest heading)
- All matching text is highlighted with theme-appropriate colors
- Highlights pulse on arrival, then fade to subtle background after 4 seconds
- Press Escape to clear highlights
- Works across all four themes (dark, light, tan, cloud)
**Technical details:**
- New `useSearchHighlighting.ts` hook with polling mechanism to wait for content load
- Search query passed via `?q=` URL parameter instead of hash anchor
- Theme-specific highlight colors matching existing design system
Updated files: `src/hooks/useSearchHighlighting.ts` (new), `src/components/SearchModal.tsx`, `src/components/BlogPost.tsx`, `src/pages/Post.tsx`, `src/styles/global.css`
## v2.8.4
Released January 3, 2026
**AI service links now use local /raw URLs**
- ChatGPT, Claude, and Perplexity links now use local `/raw/{slug}.md` URLs instead of GitHub raw URLs
- Simplified AI prompt from multi-line instructions to "Read this URL and summarize it:"
- No longer requires git push for AI links to work (synced content available immediately)
**Technical details:**
- Updated URL construction to use `window.location.origin` for consistency
- Removed unused `siteConfig` import and `getGitHubRawUrl` function
Updated files: `src/components/CopyPageDropdown.tsx`
## v2.8.3

View File

@@ -58,7 +58,7 @@ Open `fork-config.json` and update the values:
npm run configure
```
The script reads your JSON file and updates all 11 configuration files automatically. You should see output like:
The script reads your JSON file and updates all 14 configuration files automatically. You should see output like:
```
Fork Configuration Script
@@ -67,14 +67,16 @@ Reading config from fork-config.json...
Updating src/config/siteConfig.ts...
Updating src/pages/Home.tsx...
Updating src/pages/Post.tsx...
Updating src/pages/DocsPage.tsx...
Updating convex/http.ts...
Updating convex/rss.ts...
Updating netlify/edge-functions/mcp.ts...
Updating scripts/send-newsletter.ts...
Updating index.html...
Updating public/llms.txt...
Updating public/robots.txt...
Updating public/openapi.yaml...
Updating public/.well-known/ai-plugin.json...
Updating default theme in src/config/siteConfig.ts...
Configuration complete!
```
@@ -93,17 +95,19 @@ The configuration script updates these files:
| File | What changes |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, features (logo gallery, GitHub contributions, visitor map, blog page, posts display, homepage, right sidebar, footer, social footer, AI chat, newsletter, contact form, newsletter admin, stats page, MCP server, dashboard, image lightbox) |
| `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, default theme, features (logo gallery, GitHub contributions, visitor map, blog page, posts display, homepage, right sidebar, footer, social footer, AI chat, newsletter, contact form, newsletter admin, stats page, MCP server, dashboard, image lightbox) |
| `src/pages/Home.tsx` | Intro paragraph, footer links |
| `src/pages/Post.tsx` | SITE_URL, SITE_NAME constants |
| `src/pages/DocsPage.tsx` | SITE_URL constant |
| `convex/http.ts` | SITE_URL, SITE_NAME constants |
| `convex/rss.ts` | SITE_URL, SITE_TITLE, SITE_DESCRIPTION |
| `netlify/edge-functions/mcp.ts` | SITE_URL, SITE_NAME, MCP_SERVER_NAME constants |
| `scripts/send-newsletter.ts` | Default SITE_URL constant |
| `index.html` | Meta tags, JSON-LD, page title |
| `public/llms.txt` | Site info, GitHub link |
| `public/robots.txt` | Sitemap URL |
| `public/openapi.yaml` | Server URL, site name |
| `public/openapi.yaml` | Server URL, site name, example URLs |
| `public/.well-known/ai-plugin.json` | Plugin metadata |
| `src/config/siteConfig.ts` | Default theme (`defaultTheme` field) |
## Optional settings

View File

@@ -9,14 +9,16 @@
* - src/config/siteConfig.ts (site name, bio, GitHub username, features)
* - src/pages/Home.tsx (intro paragraph, footer section)
* - src/pages/Post.tsx (SITE_URL, SITE_NAME constants)
* - src/pages/DocsPage.tsx (SITE_URL constant)
* - convex/http.ts (SITE_URL, SITE_NAME constants)
* - convex/rss.ts (SITE_URL, SITE_TITLE, SITE_DESCRIPTION)
* - index.html (meta tags, JSON-LD, title)
* - public/llms.txt (site info, API endpoints)
* - public/robots.txt (sitemap URL)
* - public/openapi.yaml (server URL, site name)
* - public/openapi.yaml (server URL, site name, example URLs)
* - public/.well-known/ai-plugin.json (plugin metadata)
* - src/context/ThemeContext.tsx (default theme)
* - netlify/edge-functions/mcp.ts (SITE_URL, SITE_NAME constants)
* - scripts/send-newsletter.ts (SITE_URL fallback)
*/
import * as fs from "fs";
@@ -453,6 +455,19 @@ function updatePostTsx(config: ForkConfig): void {
]);
}
// Update DocsPage.tsx
function updateDocsPageTsx(config: ForkConfig): void {
console.log("\nUpdating src/pages/DocsPage.tsx...");
updateFile("src/pages/DocsPage.tsx", [
// Match any existing SITE_URL value (https://...)
{
search: /const SITE_URL = "https:\/\/[^"]+";/,
replace: `const SITE_URL = "${config.siteUrl}";`,
},
]);
}
// Update convex/http.ts
function updateConvexHttp(config: ForkConfig): void {
console.log("\nUpdating convex/http.ts...");
@@ -750,6 +765,8 @@ function updateOpenApiYaml(config: ForkConfig): void {
console.log("\nUpdating public/openapi.yaml...");
const githubUrl = `https://github.com/${config.githubUsername}/${config.githubRepo}`;
// Extract domain from siteUrl for example URLs (without www. if present)
const siteUrlForExamples = config.siteUrl.replace(/^https?:\/\/(www\.)?/, "https://");
updateFile("public/openapi.yaml", [
// Match any title ending with API
@@ -767,15 +784,30 @@ function updateOpenApiYaml(config: ForkConfig): void {
search: /- url: https:\/\/[^\s]+\n\s+description: Production server/,
replace: `- url: ${config.siteUrl}\n description: Production server`,
},
// Match any example site name
// Match site name example in schema (line 31)
{
search: /example: .+\n\s+url:/g,
replace: `example: ${config.siteName}\n url:`,
search: /example: markdown sync framework/,
replace: `example: ${config.siteName}`,
},
// Match any example URL (for site URL)
// Match site URL example in schema (line 34)
{
search: /example: https:\/\/[^\s]+\n\s+posts:/,
replace: `example: ${config.siteUrl}\n posts:`,
search: /example: https:\/\/markdown\.fast\n(\s+)posts:/,
replace: `example: ${siteUrlForExamples}\n$1posts:`,
},
// Match post URL example (line 167)
{
search: /example: https:\/\/markdown\.fast\/how-to-build-blog/,
replace: `example: ${siteUrlForExamples}/how-to-build-blog`,
},
// Match markdown URL example (line 170)
{
search: /example: https:\/\/markdown\.fast\/api\/post\?slug=how-to-build-blog/,
replace: `example: ${siteUrlForExamples}/api/post?slug=how-to-build-blog`,
},
// Match any remaining markdown.fast URLs
{
search: /https:\/\/(www\.)?markdown\.fast/g,
replace: siteUrlForExamples,
},
]);
}
@@ -823,6 +855,47 @@ function updateThemeConfig(config: ForkConfig): void {
]);
}
// Update netlify/edge-functions/mcp.ts
function updateMcpEdgeFunction(config: ForkConfig): void {
console.log("\nUpdating netlify/edge-functions/mcp.ts...");
updateFile("netlify/edge-functions/mcp.ts", [
// Match any existing SITE_URL constant
{
search: /const SITE_URL = "https:\/\/[^"]+";/,
replace: `const SITE_URL = "${config.siteUrl}";`,
},
// Match any existing SITE_NAME constant
{
search: /const SITE_NAME = "[^"]+";/,
replace: `const SITE_NAME = "${config.siteName}";`,
},
// Match any existing MCP_SERVER_NAME constant (create from site name)
{
search: /const MCP_SERVER_NAME = "[^"]+";/,
replace: `const MCP_SERVER_NAME = "${config.siteName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")}-mcp";`,
},
]);
}
// Update scripts/send-newsletter.ts
function updateSendNewsletter(config: ForkConfig): void {
console.log("\nUpdating scripts/send-newsletter.ts...");
updateFile("scripts/send-newsletter.ts", [
// Match any existing SITE_URL fallback in comment
{
search: /\* - SITE_URL: Your site URL \(default: https:\/\/[^)]+\)/,
replace: `* - SITE_URL: Your site URL (default: ${config.siteUrl})`,
},
// Match any existing SITE_URL fallback in code
{
search: /const siteUrl = process\.env\.SITE_URL \|\| "https:\/\/[^"]+";/,
replace: `const siteUrl = process.env.SITE_URL || "${config.siteUrl}";`,
},
]);
}
// Main function
function main(): void {
console.log("Fork Configuration Script");
@@ -837,6 +910,7 @@ function main(): void {
updateSiteConfig(config);
updateHomeTsx(config);
updatePostTsx(config);
updateDocsPageTsx(config);
updateConvexHttp(config);
updateConvexRss(config);
updateIndexHtml(config);
@@ -845,6 +919,8 @@ function main(): void {
updateOpenApiYaml(config);
updateAiPluginJson(config);
updateThemeConfig(config);
updateMcpEdgeFunction(config);
updateSendNewsletter(config);
console.log("\n=========================");
console.log("Configuration complete!");

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
@@ -10,6 +10,7 @@ import { useTheme } from "../context/ThemeContext";
import NewsletterSignup from "./NewsletterSignup";
import ContactForm from "./ContactForm";
import siteConfig from "../config/siteConfig";
import { useSearchHighlighting } from "../hooks/useSearchHighlighting";
// Whitelisted domains for iframe embeds (YouTube and Twitter/X only)
const ALLOWED_IFRAME_DOMAINS = [
@@ -483,6 +484,10 @@ export default function BlogPost({
} | null>(null);
const isLightboxEnabled = siteConfig.imageLightbox?.enabled !== false;
// Search highlighting - scrolls to and highlights search terms from URL
const articleRef = useRef<HTMLElement>(null);
useSearchHighlighting({ containerRef: articleRef });
const getCodeTheme = () => {
switch (theme) {
case "dark":
@@ -741,7 +746,7 @@ export default function BlogPost({
if (hasInlineEmbeds) {
return (
<>
<article className="blog-post-content">
<article ref={articleRef} className="blog-post-content">
{segments.map((segment, index) => {
if (segment.type === "newsletter") {
// Newsletter signup inline
@@ -777,7 +782,7 @@ export default function BlogPost({
// No inline embeds, render content normally
return (
<>
<article className="blog-post-content">
<article ref={articleRef} className="blog-post-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}

View File

@@ -53,7 +53,8 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
e.preventDefault();
if (results[selectedIndex]) {
const result = results[selectedIndex];
const url = result.anchor ? `/${result.slug}#${result.anchor}` : `/${result.slug}`;
// Pass search query as URL param for highlighting on destination page
const url = `/${result.slug}?q=${encodeURIComponent(searchQuery)}`;
navigate(url);
onClose();
}
@@ -68,8 +69,9 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
);
// Handle clicking on a result
const handleResultClick = (slug: string, anchor?: string) => {
const url = anchor ? `/${slug}#${anchor}` : `/${slug}`;
const handleResultClick = (slug: string) => {
// Pass search query as URL param for highlighting on destination page
const url = `/${slug}?q=${encodeURIComponent(searchQuery)}`;
navigate(url);
onClose();
};
@@ -136,7 +138,7 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
<li key={result._id}>
<button
className={`search-result-item ${index === selectedIndex ? "selected" : ""}`}
onClick={() => handleResultClick(result.slug, result.anchor)}
onClick={() => handleResultClick(result.slug)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="search-result-icon">

View File

@@ -411,27 +411,27 @@ export const siteConfig: SiteConfig = {
},
{
src: "/images/logos/firecrawl.svg",
href: "https://www.markdown.fast/how-to-use-firecrawl",
href: "/how-to-use-firecrawl",
},
{
src: "/images/logos/markdown.svg",
href: "https://markdown.fast/docs",
href: "/docs",
},
{
src: "/images/logos/react.svg",
href: "https://markdown.fast/setup-guide",
href: "/setup-guide",
},
{
src: "/images/logos/agentmail.svg",
href: "https://www.markdown.fast/how-to-use-agentmail/",
href: "/how-to-use-agentmail",
},
{
src: "/images/logos/mcp.svg",
href: "https://www.markdown.fast/how-to-use-mcp-server/",
href: "/how-to-use-mcp-server",
},
{
src: "/images/logos/workos.svg",
href: "https://www.markdown.fast/how-to-setup-workos",
href: "/how-to-setup-workos",
},
],
position: "above-footer",

View File

@@ -0,0 +1,206 @@
import { useEffect, useRef, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
interface UseSearchHighlightingOptions {
containerRef: React.RefObject<HTMLElement | null>;
enabled?: boolean;
}
// Maximum time to wait for content to load (ms)
const MAX_WAIT_TIME = 5000;
// How often to check for content (ms)
const POLL_INTERVAL = 100;
/**
* Hook to highlight search terms on the page and scroll to the first match.
* Reads the `?q=` query parameter from the URL and highlights all occurrences.
* Waits for content to load before attempting to highlight.
*/
export function useSearchHighlighting({
containerRef,
enabled = true,
}: UseSearchHighlightingOptions): { searchQuery: string | null } {
const [searchParams, setSearchParams] = useSearchParams();
const searchQuery = searchParams.get("q");
const hasHighlighted = useRef(false);
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear highlights function
const clearHighlights = useCallback(() => {
document.querySelectorAll("mark.search-highlight").forEach((mark) => {
const parent = mark.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(mark.textContent || ""), mark);
parent.normalize();
}
});
}, []);
// Check if container has meaningful content (not just whitespace)
const hasContent = useCallback((container: HTMLElement): boolean => {
// Check for actual text content or child elements
const textContent = container.textContent?.trim() || "";
const hasChildElements = container.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, td").length > 0;
return textContent.length > 50 || hasChildElements;
}, []);
// Perform the actual highlighting
const performHighlighting = useCallback(
(container: HTMLElement, query: string) => {
// Escape special regex characters
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
// Walk text nodes and find matches
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
// Skip code blocks and already highlighted content
const tagName = parent.tagName.toUpperCase();
if (tagName === "CODE" || tagName === "PRE" || tagName === "SCRIPT" || tagName === "STYLE") {
return NodeFilter.FILTER_REJECT;
}
if (parent.classList.contains("search-highlight")) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const textNodes: Text[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
if (regex.test(node.textContent || "")) {
textNodes.push(node as Text);
}
regex.lastIndex = 0; // Reset regex state
}
// Highlight each match
let firstMark: HTMLElement | null = null;
textNodes.forEach((textNode) => {
const text = textNode.textContent || "";
const parts = text.split(regex);
if (parts.length <= 1) return;
const fragment = document.createDocumentFragment();
parts.forEach((part, index) => {
if (index % 2 === 1) {
// This is a match
const mark = document.createElement("mark");
mark.className = "search-highlight search-highlight-active";
mark.textContent = part;
fragment.appendChild(mark);
if (!firstMark) firstMark = mark;
} else if (part) {
fragment.appendChild(document.createTextNode(part));
}
});
textNode.parentNode?.replaceChild(fragment, textNode);
});
return firstMark;
},
[]
);
// Main highlighting effect
useEffect(() => {
if (!enabled || !searchQuery || hasHighlighted.current) {
return;
}
const startTime = Date.now();
// Poll until content is available or timeout
const attemptHighlight = () => {
const container = containerRef.current;
const elapsed = Date.now() - startTime;
// Give up after MAX_WAIT_TIME
if (elapsed > MAX_WAIT_TIME) {
// Clean up URL even if we couldn't highlight
searchParams.delete("q");
setSearchParams(searchParams, { replace: true });
return;
}
// Check if container exists and has content
if (!container || !hasContent(container)) {
// Try again after POLL_INTERVAL
pollTimeoutRef.current = setTimeout(attemptHighlight, POLL_INTERVAL);
return;
}
// Content is ready, perform highlighting
const firstMark = performHighlighting(container, searchQuery);
hasHighlighted.current = true;
// Scroll to first match with offset for fixed header
if (firstMark) {
// Small delay to ensure DOM has updated after highlighting
setTimeout(() => {
if (!firstMark) return;
const headerOffset = 80;
const elementRect = firstMark.getBoundingClientRect();
const absoluteElementTop = elementRect.top + window.pageYOffset;
const offsetPosition = absoluteElementTop - headerOffset - window.innerHeight / 2 + 50;
window.scrollTo({
top: Math.max(0, offsetPosition),
behavior: "smooth",
});
}, 50);
}
// Auto-fade highlights after 4 seconds (remove active class only)
setTimeout(() => {
document.querySelectorAll(".search-highlight-active").forEach((el) => {
el.classList.remove("search-highlight-active");
});
}, 4000);
// Clean up URL (remove ?q= param) after highlighting is applied
setTimeout(() => {
searchParams.delete("q");
setSearchParams(searchParams, { replace: true });
}, 500);
};
// Start polling
pollTimeoutRef.current = setTimeout(attemptHighlight, POLL_INTERVAL);
return () => {
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
}
};
}, [searchQuery, containerRef, enabled, searchParams, setSearchParams, hasContent, performHighlighting]);
// Clear highlights on Escape key
useEffect(() => {
if (!searchQuery && !hasHighlighted.current) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
clearHighlights();
hasHighlighted.current = false;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [searchQuery, clearHighlights]);
// Reset hasHighlighted when component unmounts or query changes
useEffect(() => {
return () => {
hasHighlighted.current = false;
};
}, [searchQuery]);
return { searchQuery };
}

View File

@@ -56,6 +56,8 @@ import {
ChatText,
SpinnerGap,
CaretDown,
ArrowsOut,
ArrowsIn,
} from "@phosphor-icons/react";
import siteConfig from "../config/siteConfig";
import AIChatView from "../components/AIChatView";
@@ -525,6 +527,19 @@ function DashboardContent() {
});
}, []);
// Keyboard shortcut: Cmd+. to toggle sidebar
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === ".") {
e.preventDefault();
toggleSidebar();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// Toast notifications state
const [toasts, setToasts] = useState<Toast[]>([]);
@@ -1115,12 +1130,20 @@ function DashboardContent() {
{/* Write Post Section */}
{activeSection === "write-post" && (
<WriteSection contentType="post" />
<WriteSection
contentType="post"
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
/>
)}
{/* Write Page Section */}
{activeSection === "write-page" && (
<WriteSection contentType="page" />
<WriteSection
contentType="page"
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
/>
)}
{/* AI Agent Section */}
@@ -2033,6 +2056,13 @@ const POST_FIELDS = [
{ name: "blogFeatured", required: false, example: "true" },
{ name: "newsletter", required: false, example: "true" },
{ name: "contactForm", required: false, example: "true" },
{ name: "unlisted", required: false, example: "true" },
{ name: "docsSection", required: false, example: "true" },
{ name: "docsSectionOrder", required: false, example: "1" },
{ name: "docsSectionGroup", required: false, example: '"Setup"' },
{ name: "docsSectionGroupOrder", required: false, example: "1" },
{ name: "docsSectionGroupIcon", required: false, example: '"Rocket"' },
{ name: "docsLanding", required: false, example: "true" },
];
// Frontmatter field definitions for pages (matches Write.tsx)
@@ -2065,6 +2095,13 @@ const PAGE_FIELDS = [
{ name: "aiChat", required: false, example: "true" },
{ name: "newsletter", required: false, example: "true" },
{ name: "contactForm", required: false, example: "true" },
{ name: "unlisted", required: false, example: "true" },
{ name: "docsSection", required: false, example: "true" },
{ name: "docsSectionOrder", required: false, example: "1" },
{ name: "docsSectionGroup", required: false, example: '"Setup"' },
{ name: "docsSectionGroupOrder", required: false, example: "1" },
{ name: "docsSectionGroupIcon", required: false, example: '"Rocket"' },
{ name: "docsLanding", required: false, example: "true" },
];
// Generate frontmatter template based on content type
@@ -2129,11 +2166,75 @@ With sidebar layout enabled, headings automatically appear in the table of conte
// localStorage keys for dashboard write
const DASHBOARD_WRITE_POST_CONTENT = "dashboard_write_post_content";
const DASHBOARD_WRITE_PAGE_CONTENT = "dashboard_write_page_content";
const DASHBOARD_WRITE_FOCUS_MODE = "dashboard_write_focus_mode";
const DASHBOARD_WRITE_FRONTMATTER_COLLAPSED = "dashboard_write_frontmatter_collapsed";
function WriteSection({ contentType }: { contentType: "post" | "page" }) {
function WriteSection({
contentType,
sidebarCollapsed,
setSidebarCollapsed,
}: {
contentType: "post" | "page";
sidebarCollapsed: boolean;
setSidebarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const [content, setContent] = useState("");
const [copied, setCopied] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [focusMode, setFocusMode] = useState(() => {
const saved = localStorage.getItem(DASHBOARD_WRITE_FOCUS_MODE);
return saved === "true";
});
const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(() => {
const saved = localStorage.getItem(DASHBOARD_WRITE_FRONTMATTER_COLLAPSED);
// Default to collapsed in focus mode
return saved === "true";
});
// Store previous sidebar state before entering focus mode
const [prevSidebarState, setPrevSidebarState] = useState<boolean | null>(null);
// Toggle focus mode
const toggleFocusMode = useCallback(() => {
setFocusMode((prev) => {
const newValue = !prev;
localStorage.setItem(DASHBOARD_WRITE_FOCUS_MODE, String(newValue));
// When entering focus mode, save sidebar state and collapse it
if (newValue) {
setPrevSidebarState(sidebarCollapsed);
setSidebarCollapsed(true);
setFrontmatterCollapsed(true);
localStorage.setItem(DASHBOARD_WRITE_FRONTMATTER_COLLAPSED, "true");
} else {
// When exiting focus mode, restore previous sidebar state
if (prevSidebarState !== null) {
setSidebarCollapsed(prevSidebarState);
}
}
return newValue;
});
}, [sidebarCollapsed, setSidebarCollapsed, prevSidebarState]);
// Toggle frontmatter sidebar
const toggleFrontmatter = useCallback(() => {
setFrontmatterCollapsed((prev) => {
const newValue = !prev;
localStorage.setItem(DASHBOARD_WRITE_FRONTMATTER_COLLAPSED, String(newValue));
return newValue;
});
}, []);
// Keyboard shortcut: Escape to exit focus mode
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && focusMode) {
e.preventDefault();
toggleFocusMode();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [focusMode, toggleFocusMode]);
// localStorage key based on content type
const storageKey =
@@ -2269,7 +2370,9 @@ published: false
const fields = contentType === "post" ? POST_FIELDS : PAGE_FIELDS;
return (
<div className="dashboard-write-section">
<div
className={`dashboard-write-section ${focusMode ? "focus-mode" : ""} ${frontmatterCollapsed ? "frontmatter-collapsed" : ""}`}
>
{/* Write Actions Header */}
<div className="dashboard-write-header">
<div className="dashboard-write-title">
@@ -2303,6 +2406,17 @@ published: false
<Download size={16} />
<span>Download .md</span>
</button>
<button
onClick={toggleFocusMode}
className={`dashboard-action-btn focus-toggle ${focusMode ? "active" : ""}`}
title={focusMode ? "Exit focus mode (Esc)" : "Enter focus mode"}
>
{focusMode ? (
<ArrowsIn size={16} weight="regular" />
) : (
<ArrowsOut size={16} weight="regular" />
)}
</button>
</div>
</div>
@@ -2333,9 +2447,18 @@ published: false
</div>
{/* Frontmatter Sidebar */}
<aside className="dashboard-write-sidebar">
<aside
className={`dashboard-write-sidebar ${frontmatterCollapsed ? "collapsed" : ""}`}
>
<div className="dashboard-write-sidebar-header">
<span>Frontmatter</span>
<button
onClick={toggleFrontmatter}
className="dashboard-write-sidebar-toggle"
title={frontmatterCollapsed ? "Expand" : "Collapse"}
>
<SidebarSimple size={16} weight="regular" />
</button>
</div>
<div className="dashboard-write-fields">
<div className="write-fields-section">

View File

@@ -4,10 +4,14 @@ import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import DocsLayout from "../components/DocsLayout";
import BlogPost from "../components/BlogPost";
import CopyPageDropdown from "../components/CopyPageDropdown";
import { extractHeadings } from "../utils/extractHeadings";
import siteConfig from "../config/siteConfig";
import { ArrowRight } from "lucide-react";
// Site URL for CopyPageDropdown - update when forking
const SITE_URL = "https://www.markdown.fast";
export default function DocsPage() {
// Fetch landing page content (checks pages first, then posts)
const landingPage = useQuery(api.pages.getDocsLandingPage);
@@ -64,10 +68,25 @@ export default function DocsPage() {
// If we have landing content, render it with DocsLayout
if (landingContent) {
const headings = extractHeadings(landingContent.content);
const description =
"description" in landingContent
? landingContent.description
: "excerpt" in landingContent
? landingContent.excerpt
: undefined;
return (
<DocsLayout headings={headings} currentSlug={landingContent.slug}>
<article className="docs-article">
<div className="docs-article-actions">
<CopyPageDropdown
title={landingContent.title}
content={landingContent.content}
url={`${SITE_URL}/${landingContent.slug}`}
slug={landingContent.slug}
description={description}
/>
</div>
<header className="docs-article-header">
<h1 className="docs-article-title">{landingContent.title}</h1>
{"description" in landingContent && landingContent.description && (

View File

@@ -88,7 +88,12 @@ export default function Post({
const [copied, setCopied] = useState(false);
// Scroll to hash anchor after content loads
// Skip if there's a search query - let the highlighting hook handle scroll
useEffect(() => {
// If there's a search query, the highlighting hook handles scrolling to the match
const searchQuery = new URLSearchParams(location.search).get("q");
if (searchQuery) return;
if (!location.hash) return;
if (page === undefined && post === undefined) return;
@@ -102,7 +107,7 @@ export default function Post({
}, 100);
return () => clearTimeout(timer);
}, [location.hash, page, post]);
}, [location.hash, location.search, page, post]);
// Update sidebar context with headings for mobile menu
useEffect(() => {
@@ -663,7 +668,7 @@ export default function Post({
<p className="post-description">{post.description}</p>
)}
</header>
{/* Blog post sharing links */}
{/* Blog post content - raw markdown or rendered */}
<BlogPost content={post.content} slug={post.slug} pageType="post" />
<footer className="post-footer">

View File

@@ -10,6 +10,9 @@ import {
Warning,
TextAa,
ChatCircle,
ArrowsOut,
ArrowsIn,
SidebarSimple,
} from "@phosphor-icons/react";
import { Moon, Sun, Cloud } from "lucide-react";
import { Half2Icon } from "@radix-ui/react-icons";
@@ -60,6 +63,12 @@ const POST_FIELDS = [
{ name: "newsletter", required: false, example: "true" },
{ name: "contactForm", required: false, example: "true" },
{ name: "unlisted", required: false, example: "true" },
{ name: "docsSection", required: false, example: "true" },
{ name: "docsSectionOrder", required: false, example: "1" },
{ name: "docsSectionGroup", required: false, example: '"Setup"' },
{ name: "docsSectionGroupOrder", required: false, example: "1" },
{ name: "docsSectionGroupIcon", required: false, example: '"Rocket"' },
{ name: "docsLanding", required: false, example: "true" },
];
// Frontmatter field definitions for pages
@@ -92,6 +101,13 @@ const PAGE_FIELDS = [
{ name: "aiChat", required: false, example: "true" },
{ name: "newsletter", required: false, example: "true" },
{ name: "contactForm", required: false, example: "true" },
{ name: "unlisted", required: false, example: "true" },
{ name: "docsSection", required: false, example: "true" },
{ name: "docsSectionOrder", required: false, example: "1" },
{ name: "docsSectionGroup", required: false, example: '"Setup"' },
{ name: "docsSectionGroupOrder", required: false, example: "1" },
{ name: "docsSectionGroupIcon", required: false, example: '"Rocket"' },
{ name: "docsLanding", required: false, example: "true" },
];
// Generate frontmatter template based on content type
@@ -157,6 +173,9 @@ With sidebar layout enabled, headings automatically appear in the table of conte
const STORAGE_KEY_CONTENT = "markdown_write_content";
const STORAGE_KEY_TYPE = "markdown_write_type";
const STORAGE_KEY_FONT = "markdown_write_font";
const STORAGE_KEY_SIDEBAR = "markdown_write_sidebar_collapsed";
const STORAGE_KEY_FOCUS = "markdown_write_focus_mode";
const STORAGE_KEY_FRONTMATTER = "markdown_write_frontmatter_collapsed";
// Font family definitions (matches global.css options)
// Note: Write page uses its own font state for local editing, but respects global font on mount
@@ -192,6 +211,18 @@ export default function Write() {
const [copiedField, setCopiedField] = useState<string | null>(null);
const [font, setFont] = useState<"serif" | "sans" | "monospace">("sans");
const [isAIChatMode, setIsAIChatMode] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY_SIDEBAR);
return saved === "true";
});
const [focusMode, setFocusMode] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY_FOCUS);
return saved === "true";
});
const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY_FRONTMATTER);
return saved === "true";
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Check if AI chat is enabled for write page
@@ -271,6 +302,56 @@ export default function Write() {
});
}, []);
// Toggle sidebar collapsed state
const toggleSidebar = useCallback(() => {
setSidebarCollapsed((prev) => {
const newValue = !prev;
localStorage.setItem(STORAGE_KEY_SIDEBAR, String(newValue));
return newValue;
});
}, []);
// Toggle focus mode (hides sidebars and header for distraction-free writing)
const toggleFocusMode = useCallback(() => {
setFocusMode((prev) => {
const newValue = !prev;
localStorage.setItem(STORAGE_KEY_FOCUS, String(newValue));
// When entering focus mode, collapse frontmatter by default
if (newValue) {
setFrontmatterCollapsed(true);
localStorage.setItem(STORAGE_KEY_FRONTMATTER, "true");
}
return newValue;
});
}, []);
// Toggle frontmatter sidebar
const toggleFrontmatter = useCallback(() => {
setFrontmatterCollapsed((prev) => {
const newValue = !prev;
localStorage.setItem(STORAGE_KEY_FRONTMATTER, String(newValue));
return newValue;
});
}, []);
// Keyboard shortcut: Cmd+. to toggle sidebar
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === ".") {
e.preventDefault();
toggleSidebar();
}
// Escape to exit focus mode
if (e.key === "Escape" && focusMode) {
e.preventDefault();
toggleFocusMode();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar, toggleFocusMode, focusMode]);
// Handle type change and update content template
const handleTypeChange = (newType: "post" | "page") => {
if (newType === contentType) return;
@@ -338,14 +419,25 @@ export default function Write() {
const fields = contentType === "post" ? POST_FIELDS : PAGE_FIELDS;
return (
<div className="write-layout">
<div
className={`write-layout ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${focusMode ? "focus-mode" : ""} ${frontmatterCollapsed ? "frontmatter-collapsed" : ""}`}
>
{/* Left Sidebar: Type selector */}
<aside className="write-sidebar-left">
<aside
className={`write-sidebar-left ${sidebarCollapsed ? "collapsed" : ""}`}
>
<div className="write-sidebar-header">
<Link to="/" className="write-logo-link" title="Back to home">
<House size={20} weight="regular" />
<span>Home</span>
</Link>
<button
className="write-sidebar-toggle"
onClick={toggleSidebar}
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<SidebarSimple size={20} weight="regular" />
</button>
</div>
<nav className="write-nav">
@@ -434,31 +526,46 @@ export default function Write() {
{/* Main writing area */}
<main className="write-main">
<div className="write-main-header">
<h1 className="write-main-title">
{isAIChatMode
? "Agent"
: contentType === "post"
? "Blog Post"
: "Page"}
</h1>
{!isAIChatMode && (
<div className="write-header-left">
<h1 className="write-main-title">
{isAIChatMode
? "Agent"
: contentType === "post"
? "Blog Post"
: "Page"}
</h1>
</div>
<div className="write-header-actions">
{!isAIChatMode && (
<button
onClick={handleCopy}
className={`write-copy-btn ${copied ? "copied" : ""}`}
>
{copied ? (
<>
<Check size={16} weight="bold" />
<span>Copied</span>
</>
) : (
<>
<CopySimple size={16} />
<span>Copy All</span>
</>
)}
</button>
)}
<button
onClick={handleCopy}
className={`write-copy-btn ${copied ? "copied" : ""}`}
onClick={toggleFocusMode}
className={`write-focus-btn ${focusMode ? "active" : ""}`}
title={focusMode ? "Exit focus mode (Esc)" : "Enter focus mode"}
>
{copied ? (
<>
<Check size={16} weight="bold" />
<span>Copied</span>
</>
{focusMode ? (
<ArrowsIn size={18} weight="regular" />
) : (
<>
<CopySimple size={16} />
<span>Copy All</span>
</>
<ArrowsOut size={18} weight="regular" />
)}
</button>
)}
</div>
</div>
{/* Conditionally render textarea or AI chat */}
@@ -500,9 +607,18 @@ export default function Write() {
</main>
{/* Right Sidebar: Frontmatter fields */}
<aside className="write-sidebar-right">
<aside
className={`write-sidebar-right ${frontmatterCollapsed ? "collapsed" : ""}`}
>
<div className="write-sidebar-header">
<span className="write-sidebar-title">Frontmatter</span>
<button
onClick={toggleFrontmatter}
className="write-sidebar-toggle"
title={frontmatterCollapsed ? "Expand" : "Collapse"}
>
<SidebarSimple size={16} weight="regular" />
</button>
</div>
<div className="write-fields">

View File

@@ -3625,6 +3625,46 @@ body {
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
}
/* Search result highlighting on destination page */
.search-highlight {
background-color: var(--search-highlight-bg);
border-radius: 2px;
padding: 1px 2px;
margin: 0 -2px;
transition: background-color 0.5s ease-out;
}
.search-highlight-active {
background-color: var(--search-highlight-active);
animation: search-highlight-pulse 0.6s ease-out;
}
@keyframes search-highlight-pulse {
0% { background-color: var(--accent); }
100% { background-color: var(--search-highlight-active); }
}
/* Theme-specific search highlight colors */
:root[data-theme="dark"] {
--search-highlight-bg: rgba(250, 250, 250, 0.15);
--search-highlight-active: rgba(250, 250, 250, 0.35);
}
:root[data-theme="light"] {
--search-highlight-bg: rgba(17, 17, 17, 0.08);
--search-highlight-active: rgba(17, 17, 17, 0.18);
}
:root[data-theme="tan"] {
--search-highlight-bg: rgba(139, 115, 85, 0.15);
--search-highlight-active: rgba(139, 115, 85, 0.30);
}
:root[data-theme="cloud"] {
--search-highlight-bg: rgba(100, 116, 139, 0.12);
--search-highlight-active: rgba(100, 116, 139, 0.25);
}
/* Mobile responsive search modal */
@media (max-width: 768px) {
.search-modal-backdrop {
@@ -5528,11 +5568,271 @@ body {
border-top: 1px solid var(--border-color);
}
/* Sidebar Header with Toggle */
.write-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
/* Sidebar Toggle Button */
.write-sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.write-sidebar-toggle:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
/* Header Layout */
.write-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.write-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Focus Mode Toggle Button */
.write-focus-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.write-focus-btn:hover {
background: var(--bg-primary);
color: var(--text-primary);
border-color: var(--text-secondary);
}
.write-focus-btn.active {
background: var(--text-primary);
color: var(--bg-primary);
border-color: var(--text-primary);
}
/* Sidebar Collapsed State */
.write-layout.sidebar-collapsed {
grid-template-columns: 56px 1fr 280px;
}
.write-sidebar-left.collapsed {
width: 56px;
}
.write-sidebar-left.collapsed .write-logo-link span,
.write-sidebar-left.collapsed .write-nav-label,
.write-sidebar-left.collapsed .write-nav-item span,
.write-sidebar-left.collapsed .write-warning {
display: none;
}
.write-sidebar-left.collapsed .write-sidebar-header {
padding: 1rem 0.75rem 0.75rem;
flex-direction: column;
gap: 0.5rem;
}
.write-sidebar-left.collapsed .write-logo-link {
padding: 0.5rem;
justify-content: center;
}
.write-sidebar-left.collapsed .write-nav {
padding: 0.75rem 0.5rem;
}
.write-sidebar-left.collapsed .write-nav-section {
display: flex;
flex-direction: column;
align-items: center;
}
.write-sidebar-left.collapsed .write-nav-item {
width: 40px;
height: 40px;
padding: 0;
justify-content: center;
}
/* Frontmatter Toggle Button */
.write-frontmatter-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.write-frontmatter-btn:hover {
background: var(--bg-primary);
color: var(--text-primary);
border-color: var(--text-secondary);
}
.write-frontmatter-btn.active {
background: var(--text-primary);
color: var(--bg-primary);
border-color: var(--text-primary);
}
/* Right Sidebar Collapsed State */
.write-sidebar-right.collapsed {
width: 56px;
}
.write-sidebar-right.collapsed .write-fields {
display: none;
}
.write-sidebar-right.collapsed .write-sidebar-title {
display: none;
}
.write-sidebar-right.collapsed .write-sidebar-header {
padding: 1rem 0.75rem;
justify-content: center;
}
/* Focus Mode State - shows collapsed left sidebar with toggleable frontmatter sidebar */
.write-layout.focus-mode {
grid-template-columns: 56px 1fr 280px;
}
.write-layout.focus-mode.frontmatter-collapsed {
grid-template-columns: 56px 1fr 56px;
}
/* In focus mode, force left sidebar to collapsed state */
.write-layout.focus-mode .write-sidebar-left {
width: 56px;
}
.write-layout.focus-mode .write-sidebar-left .write-logo-link span,
.write-layout.focus-mode .write-sidebar-left .write-nav-label,
.write-layout.focus-mode .write-sidebar-left .write-nav-item span,
.write-layout.focus-mode .write-sidebar-left .write-warning {
display: none;
}
.write-layout.focus-mode .write-sidebar-left .write-sidebar-header {
padding: 1rem 0.75rem 0.75rem;
flex-direction: column;
gap: 0.5rem;
}
.write-layout.focus-mode .write-sidebar-left .write-logo-link {
padding: 0.5rem;
justify-content: center;
}
.write-layout.focus-mode .write-sidebar-left .write-nav {
padding: 0.75rem 0.5rem;
}
.write-layout.focus-mode .write-sidebar-left .write-nav-section {
display: flex;
flex-direction: column;
align-items: center;
}
.write-layout.focus-mode .write-sidebar-left .write-nav-item {
width: 40px;
height: 40px;
padding: 0;
justify-content: center;
}
.write-layout.focus-mode .write-main-header {
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.write-layout.focus-mode .write-main-title {
display: none;
}
.write-layout.focus-mode .write-header-left {
display: none;
}
.write-layout.focus-mode .write-header-actions {
margin-left: auto;
}
.write-layout.focus-mode .write-copy-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.write-layout.focus-mode .write-copy-btn:hover {
background: var(--bg-primary);
border-color: var(--text-secondary);
}
.write-layout.focus-mode .write-main {
display: flex;
flex-direction: column;
}
.write-layout.focus-mode .write-textarea {
padding: 2rem 3rem;
width: 100%;
flex: 1;
}
.write-layout.focus-mode .write-main-footer {
width: 100%;
border-top: 1px solid var(--border-color);
padding: 0.5rem 3rem;
}
/* Responsive: Tablet */
@media (max-width: 1024px) {
.write-layout {
grid-template-columns: 200px 1fr 240px;
}
.write-layout.sidebar-collapsed {
grid-template-columns: 56px 1fr 240px;
}
}
/* Responsive: Mobile */
@@ -8114,6 +8414,7 @@ body {
flex-direction: column;
overflow: hidden;
max-height: 100vh;
position: relative;
}
/* Dashboard Header */
@@ -8344,6 +8645,9 @@ body {
flex: 1;
overflow-y: auto;
padding: 0.625rem;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Dashboard List View */
@@ -10199,7 +10503,9 @@ body {
.dashboard-write-section {
display: flex;
flex-direction: column;
min-height: 500px;
flex: 1;
min-height: 0;
height: 100%;
}
.dashboard-write-header {
@@ -10259,6 +10565,7 @@ body {
flex: 1;
min-height: 0;
overflow: hidden;
height: 100%;
}
.dashboard-write-main {
@@ -10328,7 +10635,8 @@ body {
background-color: var(--background-secondary);
display: flex;
flex-direction: column;
overflow-y: auto;
overflow: hidden;
min-height: 0;
}
.dashboard-write-sidebar-header {
@@ -10337,12 +10645,14 @@ body {
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-primary);
flex-shrink: 0;
}
.dashboard-write-fields {
flex: 1;
padding: 0.75rem;
overflow-y: auto;
min-height: 0;
}
.write-fields-section {
@@ -10466,6 +10776,146 @@ body {
overflow: hidden;
}
/* Dashboard Write Focus Mode Toggle Button */
.dashboard-action-btn.focus-toggle {
width: 36px;
height: 36px;
padding: 0;
justify-content: center;
}
.dashboard-action-btn.focus-toggle span {
display: none;
}
.dashboard-action-btn.focus-toggle.active {
background: var(--text-primary);
color: var(--bg-primary);
border-color: var(--text-primary);
}
/* Frontmatter Toggle Button */
.dashboard-action-btn.frontmatter-toggle {
width: 36px;
height: 36px;
padding: 0;
justify-content: center;
}
.dashboard-action-btn.frontmatter-toggle.active {
background: var(--text-primary);
color: var(--bg-primary);
border-color: var(--text-primary);
}
/* Dashboard Write Sidebar Toggle */
.dashboard-write-sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.dashboard-write-sidebar-toggle:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
/* Dashboard Write Sidebar Header with Toggle */
.dashboard-write-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Dashboard Write Sidebar Collapsed State */
.dashboard-write-sidebar.collapsed {
width: 56px;
}
.dashboard-write-sidebar.collapsed .dashboard-write-fields {
display: none;
}
.dashboard-write-sidebar.collapsed .dashboard-write-sidebar-header span {
display: none;
}
.dashboard-write-sidebar.collapsed .dashboard-write-sidebar-header {
justify-content: center;
}
/* Dashboard Write Focus Mode State */
.dashboard-write-section.focus-mode {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
background: var(--bg-primary);
display: flex;
flex-direction: column;
}
.dashboard-write-section.focus-mode .dashboard-write-header {
padding: 0.75rem 1.5rem;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
}
.dashboard-write-section.focus-mode .dashboard-write-title {
display: none;
}
.dashboard-write-section.focus-mode .dashboard-write-actions {
margin-left: auto;
}
.dashboard-write-section.focus-mode .dashboard-write-container {
flex: 1;
display: flex;
overflow: hidden;
}
.dashboard-write-section.focus-mode .dashboard-write-main {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
}
.dashboard-write-section.focus-mode .dashboard-write-sidebar {
width: 280px;
flex-shrink: 0;
}
.dashboard-write-section.focus-mode.frontmatter-collapsed .dashboard-write-sidebar {
width: 56px;
}
.dashboard-write-section.focus-mode .dashboard-write-textarea {
padding: 2rem 3rem;
}
.dashboard-write-section.focus-mode .dashboard-write-footer {
width: 100%;
border-top: 1px solid var(--border-color);
padding: 0.5rem 3rem;
}
.dashboard-write-section.focus-mode .dashboard-write-warning {
display: none;
}
/* Config field note styling */
.config-field-note {
display: block;