Appearance
07_DOCS-SITE
A step-by-step guide for deploying a lightweight, client-side documentation site on any domain. Zero build tools, zero server processes, zero backend dependencies — just static files served by Apache/Nginx.
What You Get
A documentation site at a subdomain (e.g., docs.yourdomain.com) that:
- Renders
.mdfiles client-side usingmarked.js(CDN) - Has a two-column layout: sidebar navigation + content area
- Supports hash-based routing (
#section/page-name) - Includes real-time sidebar search
- Has syntax-highlighted code blocks via
highlight.js - Is fully responsive (mobile sidebar overlay)
- Deploys as static files alongside your existing app
- Requires no build step — edit markdown, push, done
File Structure
docs-site/
index.html # Page shell — nav, sidebar, content area
style.css # Prose typography + sidebar styles
app.js # Hash router, markdown renderer, sidebar builder
docs.json # Navigation manifest (drives sidebar)
.htaccess # Apache: allow .md files to be served (see Gotchas)
content/
section-a/
first-doc.md # Your markdown content
second-doc.md
section-b/
another-doc.md~7-8 files to start, ~700 lines total. No existing application files modified.
Step 1: Create the Page Shell (index.html)
The single HTML file that loads everything. Key components:
Head
html
<!-- Tailwind CDN (play mode) with your brand colors -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { /* your brand color scale */ },
accent: { /* your accent color scale */ },
}
}
}
}
</script>
<!-- Alpine.js for sidebar toggle + reactive state -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Marked.js for markdown parsing -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Highlight.js for syntax highlighting -->
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css">
<link rel="stylesheet" href="style.css">Body Layout
┌─────────────────────────────────────────────┐
│ Top Nav: Brand + "Docs" badge + ext links │
├──────────┬──────────────────────────────────┤
│ Sidebar │ │
│ (w-72) │ Content Area (rendered markdown)│
│ │ │
│ - Search │ - Breadcrumb │
│ - Tabs │ - Article content │
│ - Items │ - Welcome / Loading / 404 state │
│ │ │
└──────────┴──────────────────────────────────┘Key features:
- Top nav: Sticky, branded, with links back to main site / app
- Sidebar: Fixed on desktop (
lg:sticky), slide-in overlay on mobile with backdrop - Section tabs: Toggle between content audiences (e.g., "User Guide" / "Developer")
- Search input: Filters sidebar items client-side by title
- Collapsible categories: Group related docs under headings
- Content area: Three states — welcome (no doc selected), loading spinner, rendered markdown
- Mobile: Hamburger button toggles sidebar, click overlay to close
The entire shell uses Alpine.js x-data for state management: sidebarOpen, activeSection, currentSlug, searchQuery, renderedHtml, loading, notFound.
Script Tag
html
<script src="app.js"></script> <!-- At end of body, after Alpine -->Step 2: Create Typography Styles (style.css)
Since Tailwind CDN play mode doesn't include @tailwindcss/typography, create prose styles manually. Target a wrapper class (e.g., .prose-docs) applied to your content area.
Elements to Style
| Element | Key Styles |
|---|---|
h1 | Large, bold, bottom border, margin-top: 0 |
h2 | Medium, semibold, generous margin-top |
h3, h4 | Smaller headings with appropriate spacing |
p | 0.75rem vertical margins |
a | Brand-colored with underline |
strong, em | Appropriate weight/style |
ul, ol | Proper list-style, left padding |
blockquote | Left border (brand color), tinted background |
code (inline) | Gray background, red text, rounded |
pre | Dark background, light text, rounded, overflow-x scroll |
pre code | Reset inline code styles (transparent bg, inherit color) |
table | Full width, collapsed borders, header background |
th, td | Padding, bottom borders, hover row highlight |
hr | Simple top border, generous vertical margin |
img | max-width 100%, rounded |
Additional Styles
css
/* Alpine.js cloak */
[x-cloak] { display: none !important; }
/* Sidebar scrollbar (webkit) */
aside::-webkit-scrollbar { width: 4px; }
/* highlight.js override for dark code blocks */
.prose-docs pre .hljs { background: transparent; color: inherit; }Step 3: Create the Navigation Manifest (docs.json)
A JSON file that defines the sidebar structure. The app fetches this on load.
json
{
"title": "Your App Docs",
"sections": [
{
"id": "user",
"label": "User Guide",
"categories": [
{
"label": "Getting Started",
"items": [
{
"slug": "user/quickstart",
"title": "Quick Start",
"file": "user/quickstart.md"
},
{
"slug": "user/account",
"title": "Account Setup",
"file": "user/account.md"
}
]
}
]
},
{
"id": "dev",
"label": "Developer",
"categories": [
{
"label": "Architecture",
"items": [
{
"slug": "dev/architecture",
"title": "System Architecture",
"file": "dev/architecture.md"
}
]
}
]
}
]
}To add a new doc: Create the .md file in content/, add one entry to docs.json. That's it.
Step 4: Create the Router + Renderer (app.js)
An Alpine.js component function that handles all client-side logic.
Core Functions
| Function | Purpose |
|---|---|
init() | Fetch docs.json, configure marked, listen for hashchange |
onHashChange() | Parse hash, find item in manifest, load doc |
findItem(slug) | Search manifest sections → categories → items for matching slug |
loadDoc(item) | Fetch content/{file}, marked.parse(), inject HTML, run hljs.highlightElement() on code blocks |
showNotFound() | Display "Doc not found" state |
navigateTo(item) | Set window.location.hash |
matchesSearch(item) | Check if item title matches search query |
filteredHasItems(category) | Check if category has any visible items after search filter |
activeSectionLabel() | Return label of active section for breadcrumb |
Router Flow
URL: docs.example.com/#user/quickstart
└─── slug
│
▼
findItem("user/quickstart")
│
▼
fetch("content/user/quickstart.md")
│
▼
marked.parse(md) → renderedHtml
│
▼
hljs.highlightElement() on <pre><code> blocksKey Implementation Details
- Use
$nextTick()to run highlight.js after Alpine renders the HTML - Update
document.titleon each navigation - Set
activeSectionbased on the slug prefix (e.g.,"user"from"user/quickstart") - On bare URL (no hash), show a welcome/landing state instead of loading a doc
Step 5: Write Placeholder Content
Create 2-3 markdown docs that exercise all rendering features:
- Headings (h1-h4)
- Tables (with header row)
- Ordered and unordered lists
- Blockquotes (tips, notes)
- Inline code and fenced code blocks (with language hints)
- Bold, italic, links
- Horizontal rules
This validates your typography styles before adding real content.
Step 6: Deploy to Subdomain
6a. Create the Subdomain (cPanel)
- Log into cPanel for your hosting account
- Go to Domains → Create A New Domain
- Enter your subdomain:
docs.yourdomain.com - Set Document Root to point at your docs-site directory
- If your docs-site deploys inside your app directory (e.g., via FTP sync), point the document root there:
public_html/your-app-dir/docs-site - If standalone, use the default cPanel path
- If your docs-site deploys inside your app directory (e.g., via FTP sync), point the document root there:
Note: cPanel automatically creates the DNS A record and Apache vhost when you create a subdomain. No manual DNS or vhost config needed.
6b. Add .htaccess for .md Files
This is critical. Apache blocks .md files by default (403 Forbidden). Create a .htaccess file in your docs-site root:
apache
# Allow .md files to be served
AddType text/plain .md
<FilesMatch "\.md$">
Require all granted
</FilesMatch>Without this, the markdown fetch requests will return 403 and all docs will show "not found."
6c. Push / Deploy Files
Get your files to the server:
- FTP pipeline (GitHub Actions, etc.): Ensure your docs-site directory is not in the exclude list. Push to trigger deploy.
- Manual FTP: Upload the entire
docs-site/directory to the document root path. - Git-based deploy: Pull on server, files are already in place.
6d. SSL Certificate
If using cPanel/WHM with AutoSSL:
- WHM → SSL/TLS → AutoSSL
- Find your account → Run AutoSSL
- AutoSSL automatically picks up the new subdomain
For other environments, use Certbot or your hosting provider's SSL tooling.
6e. Verify
Test in this order:
- Raw
.mdfile access: Visithttps://docs.yourdomain.com/content/section/doc.md— should show raw markdown text (not 403) - Main page: Visit
https://docs.yourdomain.com— should show the docs shell with sidebar - Navigation: Click a sidebar item — hash changes, content loads and renders
- Deep link: Paste a full URL like
https://docs.yourdomain.com/#section/doc-name— correct doc loads directly
Gotchas & Lessons Learned
Apache blocks .md files (403 Forbidden)
Apache does not serve .md files by default. You must add a .htaccess with AddType text/plain .md and a Require all granted rule for .md files. This is the most common deployment issue.
CSP (Content Security Policy) allowlist
If your main application has a CSP header, make sure it allows the CDN domains used by the docs site:
cdn.tailwindcss.com(Tailwind)cdn.jsdelivr.net(Alpine.js, marked.js, highlight.js)
If the docs site is on a separate subdomain, it has its own headers — but if you add CSP via .htaccess or server config, include these.
file:// protocol works for local dev
The docs site works from file:// in a browser during development. Just open docs-site/index.html directly. The only caveat: fetch() for local .md files works in most browsers but may require a local server in some configurations. If it fails, use python -m http.server 8080 in the docs-site/ directory.
Hash routing vs. path routing
This system uses hash routing (#section/page) instead of path routing (/section/page). Benefits:
- Works with
file://for local dev - No server-side routing config needed
- No
FallbackResourceor rewrite rules required - Deep links work without server cooperation
Tradeoff: URLs have a # in them. For most documentation sites this is perfectly acceptable.
Tailwind CDN play mode
The Tailwind CDN script is "play mode" — it processes classes at runtime. This shows a console warning in production and is slightly slower than a compiled build. For a docs site with minimal traffic, this is a fine tradeoff to avoid a build step.
Adding new docs
The workflow for adding a new documentation page:
- Create the
.mdfile incontent/{section}/ - Add one entry to
docs.jsonunder the appropriate section and category - Push / deploy
- Done — no build, no restart, no config changes
Optional Enhancements
These are not required but can be added incrementally:
| Enhancement | Description |
|---|---|
| Table of contents | Parse rendered <h2>/<h3> tags and build a floating TOC in the right margin |
| Edit on GitHub link | Add a link to each doc's source file on GitHub for easy contributions |
| Version selector | Dropdown to switch between doc versions (separate content directories per version) |
| Print styles | CSS @media print rules to hide sidebar and format content for printing |
| Last updated date | Show file modification date (requires a build step or API to get git timestamps) |
| Previous/Next nav | Bottom navigation links based on manifest order |
| Breadcrumb links | Make breadcrumb segments clickable |
| Dark mode | Toggle between light/dark themes with Alpine.js state + CSS variables |
| Full-text search | Index all .md content into a JSON file at build time, search client-side |
| Link to main app nav | Add a "Docs" link in your main application's navigation bar |
Quick Reference
| Task | How |
|---|---|
| Add a doc | Create .md file + add entry to docs.json |
| Add a section | Add new object to sections[] in docs.json |
| Add a category | Add new object to categories[] in a section |
| Change branding | Edit tailwind.config colors + nav text in index.html |
| Change typography | Edit style.css prose rules |
| Deploy | Push to trigger FTP sync (or manual upload) |
| Local preview | Open docs-site/index.html in browser |