Version: 1.0
Last Updated: December 2025
Status: Ready for Development
EmberDocs is a source-available documentation framework designed for indie developers who need simple, extensible, and beautiful documentation without the complexity of enterprise solutions. The product addresses two core problems: documentation is annoying to maintain for developers and difficult to navigate for users.
Mission: Provide drop-in documentation that works in 30 seconds, grows with your needs, and respects user privacy.
Target Market: Indie developers, OSS maintainers, small teams who want free, self-hosted documentation with optional premium hosting.
Business Model:
- Self-hosted (Free): Full-featured framework, unlimited usage, self-hosted
- Premium ($15/mo): Hosted version with zero-deployment, AI-powered search, advanced analytics
- Zero-Config to Start: (Planned)
npx emberdocs initwill create working documentation in 30 seconds. Currently, users clone the repo and runnpm install. - True Portability: Works as standalone site, deployable anywhere Next.js runs
- Privacy-First: Client-side search, no tracking, privacy-first architecture (no external services)
- Git-Native: Version detection from git tags (routing planned for Phase 02), no manual version management
- Developer Experience: Hot reload, broken link detection, markdown-only workflow
| Feature | EmberDocs | Docusaurus | GitBook | VitePress |
|---|---|---|---|---|
| Setup time | Typically 2 minutes (with Node.js 18+) | 15 minutes | 5 minutes | 10 minutes |
| Easy deployment | ✅ | ❌ | ❌ | ❌ |
| Self-hosted (free) | ✅ | ✅ | ❌ | ✅ |
| Zero config | ✅ | ❌ | Partial | Partial |
| Privacy-first analytics | ✅ | ❌ | ❌ | ❌ |
| Semantic AI search | ✅ (opt-in) | ❌ | ❌ | ❌ |
Frontend:
- Next.js 16+ (App Router)
- React 18+
- TypeScript 5+
- Tailwind CSS 3+
Search:
- FlexSearch (client-side, default)
- OpenAI/Anthropic (semantic search, opt-in)
Content Processing:
- Remark/Rehype (markdown processing)
- Shiki (syntax highlighting)
- Gray-matter (frontmatter parsing)
Deployment:
- Vercel (primary, edge functions)
- Netlify (alternative)
- GitHub Pages (static export)
- Docker (self-hosted)
Analytics:
- Firefly Stats integration (privacy-friendly)
- Custom events for doc effectiveness
┌─────────────────────────────────────────────────────────────┐
│ User Interface │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Sidebar │ │ Search │ │ Content │ │ Version │ │
│ │ Nav │ │ Box │ │ Area │ │ Switcher │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Core Processing Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Markdown │ │ Navigation │ │ Version │ │
│ │ Parser │ │ Generator │ │ Manager │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Data Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Markdown │ │ Search │ │ Git │ │
│ │ Files │ │ Index │ │ Metadata │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
emberdocs/
├── packages/
│ ├── core/ # Main Next.js application
│ │ ├── src/
│ │ │ ├── app/ # Next.js app router
│ │ │ ├── components/ # React components
│ │ │ │ ├── DocLayout.tsx
│ │ │ │ ├── Sidebar.tsx
│ │ │ │ ├── Search.tsx
│ │ │ │ ├── ContentArea.tsx
│ │ │ │ ├── VersionSwitcher.tsx
│ │ │ │ └── CodeBlock.tsx
│ │ │ ├── lib/ # Core utilities
│ │ │ │ ├── markdown.ts
│ │ │ │ ├── navigation.ts
│ │ │ │ ├── search.ts
│ │ │ │ ├── versioning.ts
│ │ │ │ └── config.ts
│ │ │ ├── styles/ # Global styles
│ │ │ └── types/ # TypeScript types
│ │ ├── public/
│ │ │ └── docs/ # User's markdown files
│ │ ├── emberdocs.config.js
│ │ └── package.json
│ │
│ ├── cli/ # CLI tool
│ │ ├── src/
│ │ │ ├── commands/
│ │ │ │ ├── init.ts # Initialize new project
│ │ │ │ ├── build.ts # Build static site
│ │ │ │ ├── dev.ts # Development server
│ │ │ │ └── deploy.ts # Deploy helpers
│ │ │ ├── templates/ # Project scaffolding
│ │ │ └── utils/
│ │ └── package.json
│ │
├── examples/
│ ├── standalone/ # Full standalone site
│ └── nextjs-integration/ # Next.js integration
│
├── docs/ # EmberDocs own documentation
│ ├── index.md
│ ├── getting-started.md
│ ├── configuration.md
│ ├── theming.md
│ └── api/
│
└── tools/
├── migrate/ # Migration scripts
└── scripts/ # Build/deploy scripts
Purpose: Transform markdown files into structured HTML with metadata.
Features:
- Frontmatter parsing (title, description, category, etc.)
- GitHub Flavored Markdown support
- Syntax highlighting with Shiki
- Auto-linking headings
- Table of contents generation
- Code block enhancements (copy button, filename, line numbers)
API:
interface ParsedMarkdown {
content: string; // Rendered HTML
metadata: MarkdownMetadata;
toc: TableOfContents[];
excerpt: string; // First paragraph
readingTime: number; // Estimated minutes
}
interface MarkdownMetadata {
title: string;
description?: string;
category?: string;
tags?: string[];
date?: string;
author?: string;
version?: string;
}
async function parseMarkdown(
filepath: string,
options?: ParseOptions
): Promise<ParsedMarkdown>Implementation Details:
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeShiki from '@shikijs/rehype';
import rehypeStringify from 'rehype-stringify';
import matter from 'gray-matter';
export async function parseMarkdown(
filepath: string,
options: ParseOptions = {}
): Promise<ParsedMarkdown> {
const rawContent = await fs.readFile(filepath, 'utf-8');
const { data: metadata, content } = matter(rawContent);
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeShiki, {
themes: {
light: 'github-light',
dark: 'github-dark',
},
})
.use(rehypeStringify);
const result = await processor.process(content);
return {
content: String(result),
metadata: metadata as MarkdownMetadata,
toc: extractTOC(result),
excerpt: extractExcerpt(content),
readingTime: calculateReadingTime(content),
};
}Purpose: Auto-generate sidebar navigation from folder structure.
Features:
- Recursive directory scanning
- Alphabetical or custom ordering
- Collapsible sections
- Active page highlighting
- Breadcrumb generation
API:
interface NavItem {
title: string;
path: string;
children?: NavItem[];
icon?: string;
order?: number;
}
interface NavigationStructure {
items: NavItem[];
breadcrumbs: NavItem[];
prev?: NavItem;
next?: NavItem;
}
async function generateNavigation(
docsPath: string,
currentPath?: string
): Promise<NavigationStructure>Folder Structure Parsing:
docs/
├── index.md → Home
├── getting-started.md → Getting Started
├── 01-installation.md → Installation (ordered)
├── 02-configuration.md → Configuration (ordered)
├── api/
│ ├── index.md → API / Overview
│ ├── authentication.md → API / Authentication
│ └── endpoints.md → API / Endpoints
└── guides/
├── quickstart.md → Guides / Quickstart
└── advanced.md → Guides / Advanced
Ordering Rules:
- Items with
orderfield in frontmatter are sorted numerically (lower numbers first) - Items without
orderfield are sorted alphabetically after ordered items - Folders always appear before documents in the same parent
- Within each group (ordered vs. unordered), the respective sorting applies
Custom Ordering via Frontmatter:
Add an order field to your markdown file's frontmatter:
---
title: Getting Started
order: 1
---
# Getting Started---
title: Installation
order: 2
---
# InstallationFiles without an order field will be sorted alphabetically after all ordered items. This allows you to control sidebar order without renaming files.
Purpose: Provide instant, client-side search with optional AI enhancement.
Default: FlexSearch (Client-Side)
interface SearchIndex {
id: string;
title: string;
content: string;
path: string;
category?: string;
}
interface SearchResult {
item: SearchIndex;
score: number;
matches: string[];
}
class SearchClient {
private index: FlexSearch.Index;
async buildIndex(docs: ParsedMarkdown[]): Promise<void>
async search(query: string, limit?: number): Promise<SearchResult[]>
}Build Process:
// Build search index at compile time
export async function buildSearchIndex(
docsPath: string
): Promise<SearchIndex[]> {
const files = await glob(`${docsPath}/**/*.md`);
const docs = await Promise.all(
files.map(async (file) => {
const parsed = await parseMarkdown(file);
return {
id: fileToPath(file),
title: parsed.metadata.title || fileToTitle(file),
content: stripHTML(parsed.content),
path: fileToPath(file),
category: parsed.metadata.category,
};
})
);
// Write to public/search-index.json
await fs.writeFile(
'public/search-index.json',
JSON.stringify(docs)
);
return docs;
}Optional: AI Semantic Search
interface AISearchConfig {
provider: 'openai' | 'anthropic';
apiKey: string;
model: string;
embeddingDimensions?: number;
}
class AISearchClient {
async buildEmbeddings(docs: ParsedMarkdown[]): Promise<void>
async semanticSearch(query: string): Promise<SearchResult[]>
}Embedding Generation:
// Only runs when AI enabled in config
export async function buildAIIndex(
docs: SearchIndex[],
config: AISearchConfig
): Promise<void> {
const embeddings = await Promise.all(
docs.map(async (doc) => {
const embedding = await generateEmbedding(
`${doc.title}\n${doc.content}`,
config
);
return {
id: doc.id,
embedding,
};
})
);
// Store embeddings for runtime search
await fs.writeFile(
'public/embeddings.json',
JSON.stringify(embeddings)
);
}
async function generateEmbedding(
text: string,
config: AISearchConfig
): Promise<number[]> {
if (config.provider === 'openai') {
const response = await fetch(
'https://api.openai.com/v1/embeddings',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text,
}),
}
);
const data = await response.json();
return data.data[0].embedding;
}
// Similar for Anthropic
}Purpose: Auto-detect versions from git tags and manage version switching.
Features:
- Git tag detection (semver pattern)
- Version comparison
- Changelog generation
- Version switcher UI data
API:
interface Version {
tag: string;
date: string;
isLatest: boolean;
docsPath: string;
}
interface VersionComparison {
from: string;
to: string;
added: string[];
modified: string[];
removed: string[];
}
async function detectVersions(): Promise<Version[]>
async function compareVersions(
from: string,
to: string
): Promise<VersionComparison>Git Tag Detection:
import { execSync } from 'child_process';
export async function detectVersions(): Promise<Version[]> {
try {
// Get all tags matching semver pattern
const tags = execSync('git tag -l "v*.*.*"', {
encoding: 'utf-8',
})
.trim()
.split('\n')
.filter(Boolean);
const versions = await Promise.all(
tags.map(async (tag) => {
const date = execSync(`git log -1 --format=%ai ${tag}`, {
encoding: 'utf-8',
}).trim();
return {
tag,
date,
isLatest: false,
docsPath: `/versions/${tag}`,
};
})
);
// Sort by semver
versions.sort((a, b) =>
semver.rcompare(a.tag, b.tag)
);
// Mark latest
if (versions.length > 0) {
versions[0].isLatest = true;
}
return versions;
} catch (error) {
// No git or no tags
return [];
}
}Build Multi-Version Docs:
export async function buildVersionedDocs(
versions: Version[]
): Promise<void> {
for (const version of versions) {
// Checkout tag
execSync(`git checkout ${version.tag}`);
// Build docs for this version
await buildDocs({
outputDir: `public/versions/${version.tag}`,
});
}
// Return to main branch
execSync('git checkout main');
// Create symlink for latest
await fs.symlink(
`versions/${versions[0].tag}`,
'public/latest'
);
}Purpose: Load and validate user configuration.
Config File Schema:
interface EmberDocsConfig {
// Site metadata
site: {
title: string;
description?: string;
url?: string;
logo?: string;
};
// Content
docs: {
path: string; // Default: './docs'
exclude?: string[]; // Files/folders to ignore
};
// Theming
theme: {
colors?: {
primary?: string;
background?: string;
text?: string;
code?: string;
};
fonts?: {
sans?: string;
mono?: string;
};
borderRadius?: string;
};
// Search
search: {
provider: 'flexsearch' | 'ai';
aiConfig?: {
provider: 'openai' | 'anthropic';
apiKey: string;
model: string;
};
};
// Versioning
versions?: {
strategy: 'git-tags' | 'manual' | 'folders';
versions?: string[]; // For manual strategy
changelog?: {
enabled: boolean;
compareStrategy: 'git-diff' | 'manual';
};
};
// Analytics
analytics?: {
firefly?: {
siteId: string;
};
};
// Navigation
navigation?: {
customOrder?: NavItem[];
showBreadcrumbs?: boolean;
collapsible?: boolean;
};
}Default Config:
// emberdocs.config.js
module.exports = {
site: {
title: 'Documentation',
},
docs: {
path: './docs',
},
theme: {
colors: {
primary: '#8B5CF6',
},
fonts: {
sans: 'Inter, system-ui, sans-serif',
mono: 'JetBrains Mono, monospace',
},
},
search: {
provider: 'flexsearch',
},
versions: {
strategy: 'git-tags',
changelog: {
enabled: true,
compareStrategy: 'git-diff',
},
},
};Purpose: Main layout wrapper with sidebar, content area, and search.
interface DocLayoutProps {
navigation: NavigationStructure;
currentPath: string;
children: React.ReactNode;
config: EmberDocsConfig;
}
export function DocLayout({
navigation,
currentPath,
children,
config,
}: DocLayoutProps) {
return (
<div className="ed-layout">
<header className="ed-header">
<Logo config={config} />
<Search />
<VersionSwitcher />
<ThemeToggle />
</header>
<div className="ed-main">
<Sidebar
navigation={navigation}
currentPath={currentPath}
/>
<main className="ed-content">
{children}
</main>
<aside className="ed-toc">
<TableOfContents />
</aside>
</div>
</div>
);
}Purpose: Instant search with keyboard shortcuts and smart results.
interface SearchProps {
searchIndex: SearchIndex[];
onSelect?: (result: SearchResult) => void;
}
export function Search({ searchIndex, onSelect }: SearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
// Keyboard shortcut: Cmd+K or Ctrl+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setIsOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const handleSearch = async (q: string) => {
setQuery(q);
if (q.length < 2) {
setResults([]);
return;
}
const searchResults = await searchClient.search(q, 10);
setResults(searchResults);
};
return (
<div className="ed-search">
<button
onClick={() => setIsOpen(true)}
className="ed-search-trigger"
>
<SearchIcon />
<span>Search...</span>
<kbd>⌘K</kbd>
</button>
{isOpen && (
<SearchModal
query={query}
results={results}
onQueryChange={handleSearch}
onClose={() => setIsOpen(false)}
onSelect={onSelect}
/>
)}
</div>
);
}Purpose: Enhanced code blocks with copy, filename, and line numbers.
interface CodeBlockProps {
code: string;
language: string;
filename?: string;
showLineNumbers?: boolean;
highlightLines?: number[];
}
export function CodeBlock({
code,
language,
filename,
showLineNumbers = false,
highlightLines = [],
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="ed-code-block">
{filename && (
<div className="ed-code-header">
<span className="ed-code-filename">{filename}</span>
<button
onClick={handleCopy}
className="ed-code-copy"
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
</div>
)}
<pre className={`language-${language}`}>
<code>{code}</code>
</pre>
</div>
);
}Status: Planned for Phase 02 / Future Version (see dev-docs/planning/cli_tool_implementation.md)
The CLI tool is not yet implemented. Users currently need to clone the repository and set up manually. The CLI functionality described below is planned for future releases.
init - Initialize New Project
npx emberdocs init [directory]
Options:
--minimal Create minimal setup (no examples)
--integrate Add to existing Next.js project
--template Use specific templateImplementation:
export async function init(
directory: string,
options: InitOptions
): Promise<void> {
const targetDir = path.resolve(directory);
if (options.integrate) {
// Add to existing project
await addToExistingProject(targetDir);
return;
}
// Create new project
await createDirectory(targetDir);
if (options.minimal) {
await scaffoldMinimal(targetDir);
} else {
await scaffoldFull(targetDir);
}
await installDependencies(targetDir);
console.log(`
✓ EmberDocs initialized in ${directory}
Get started:
cd ${directory}
npm run dev
Then open http://localhost:3000
`);
}build - Build Static Site
npx emberdocs build
Options:
--output <dir> Output directory (default: out)
--static Static export onlydev - Development Server
npx emberdocs dev
Options:
--port <number> Port number (default: 3000)
--host <string> Host addressdeploy - Deploy Helpers
npx emberdocs deploy <platform>
Platforms:
vercel Deploy to Vercel
netlify Deploy to Netlify
github-pages Deploy to GitHub Pages// next.config.js
module.exports = {
output: 'export',
images: {
unoptimized: true,
},
trailingSlash: true,
};// vercel.json
{
"buildCommand": "npx emberdocs build",
"framework": "nextjs",
"regions": ["iad1", "sfo1", "cdg1"],
"functions": {
"app/**/*": {
"runtime": "edge"
}
}
}# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]Global Performance:
- Time to First Byte (TTFB): < 50ms (edge deployment)
- First Contentful Paint (FCP): < 500ms
- Largest Contentful Paint (LCP): < 1s
- Time to Interactive (TTI): < 1.5s
Search Performance:
- Index build time: < 5s for 1000 pages
- Search latency: < 50ms for client-side
- AI search latency: < 500ms with caching
Build Performance:
- 100 pages: < 10s
- 1000 pages: < 60s
- Incremental builds: < 2s per changed file
Asset Sizes:
- Initial bundle: < 100KB (gzipped)
- Search index: < 1KB per page
- AI embeddings: ~2KB per page (opt-in)
Content Security:
- Sanitize markdown output (XSS prevention)
- CSP headers for static assets
- No inline scripts in production
API Security:
- Rate limiting on search endpoints
- API key validation for AI features
User Privacy:
- No tracking by default
- Analytics opt-in only
- GDPR-compliant data handling
- Client-side search (no query logging)
Modern Browsers:
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Mobile Safari 14+
- Chrome Android 90+
Graceful Degradation:
- JavaScript disabled: Static HTML navigation works
- No CSS Grid: Fallback to flexbox
- Old browsers: Basic styled content accessible
WCAG 2.1 Level AA Compliance:
- Semantic HTML structure
- Keyboard navigation throughout
- Screen reader optimized
- Color contrast ratios > 4.5:1
- Focus indicators visible
- ARIA labels where needed
- Skip navigation links
Keyboard Shortcuts:
Cmd/Ctrl + K: Open search/: Focus searchEsc: Close modalsArrow keys: Navigate resultsTab: Navigate sections
Unit Tests:
- Markdown parser
- Navigation generator
- Search algorithms
- Version detection
Integration Tests:
- Build process
- Search indexing
- Version switching
- Component interactions
E2E Tests:
- Full user workflows
- Cross-browser testing
- Mobile responsive
- Accessibility audit
Performance Tests:
- Lighthouse CI
- Bundle size monitoring
- Search performance
- Build time tracking
From Docusaurus:
npx emberdocs migrate --from docusaurus
# Converts:
# - docusaurus.config.js → emberdocs.config.js
# - sidebars.js → auto-generated
# - Moves docs/ to docs/From VitePress:
npx emberdocs migrate --from vitepress
# Converts:
# - .vitepress/config.js → emberdocs.config.js
# - Adjusts markdown frontmatter
# - Converts custom componentsFrom GitBook:
npx emberdocs migrate --from gitbook
# Converts:
# - SUMMARY.md → navigation structure
# - .gitbook.yaml → emberdocs.config.jsThis specification provides the foundation for v1.0 development. Additional detailed specifications for database schema, brand guidelines, and development roadmap follow in separate documents.