feat: add Slate documentation templates to skeleton
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Adds complete Slate documentation infrastructure to generated projects:
- docs/ directory with Gemfile, config.rb, and source templates
- Dockerfile for building docs site
- Dockerfile.nginx for serving static docs
- generate-docs.sh script for CI integration
- Claude command for AI-assisted docs generation
- OpenAPI → Slate markdown conversion via widdershins
Also includes:
- --export-openapi flag for service binaries
- DNS provisioning for docs.{domain} subdomain
- Updated project_infra for docs DNS records
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f64377116a
commit
af91bad0ff
@ -7,13 +7,13 @@
|
||||
Push to main branch triggers Woodpecker CI to build and deploy automatically:
|
||||
|
||||
```bash
|
||||
# Just push - Woodpecker handles the rest
|
||||
# Push to Gitea - Woodpecker handles the rest
|
||||
# origin is now Gitea (CI) - push triggers Woodpecker automatically
|
||||
git push origin main
|
||||
|
||||
# Or push to both remotes
|
||||
# If you need to push to GitHub backup:
|
||||
GITEA_TOKEN=$(kubectl get secret rdev-credentials -n rdev -o jsonpath='{.data.GITEA_TOKEN}' | base64 -d)
|
||||
git push https://jordan:${GITEA_TOKEN}@git.threesix.ai/jordan/rdev.git main
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Images are built via kaniko and pushed to `registry.threesix.ai/rdev/*`.
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"{{GO_MODULE}}/pkg/app"
|
||||
"{{GO_MODULE}}/pkg/logging"
|
||||
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory"
|
||||
@ -10,6 +14,22 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse flags
|
||||
exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit")
|
||||
flag.Parse()
|
||||
|
||||
// If exporting OpenAPI, generate spec and exit (used by CI for docs generation)
|
||||
if *exportOpenAPI {
|
||||
spec := api.NewServiceSpec()
|
||||
jsonBytes, err := spec.JSON()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to generate OpenAPI spec: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(string(jsonBytes))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Create logger
|
||||
logger := logging.Default()
|
||||
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
# Generate API Documentation
|
||||
|
||||
Regenerate the Slate API documentation from OpenAPI specs.
|
||||
|
||||
## Usage
|
||||
|
||||
Run this command when you need to preview documentation changes locally or after modifying OpenAPI specs.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Ensure services are running locally**
|
||||
```bash
|
||||
./scripts/dev.sh
|
||||
```
|
||||
|
||||
2. **Generate markdown from OpenAPI specs**
|
||||
```bash
|
||||
chmod +x docs/scripts/generate-docs.sh
|
||||
./docs/scripts/generate-docs.sh http://localhost
|
||||
```
|
||||
|
||||
3. **Build Slate documentation locally** (optional)
|
||||
```bash
|
||||
cd docs
|
||||
bundle install
|
||||
bundle exec middleman serve
|
||||
```
|
||||
Then open http://localhost:4567 to preview.
|
||||
|
||||
## What This Does
|
||||
|
||||
1. Discovers all services with OpenAPI specs
|
||||
2. Fetches `/openapi.json` from each running service
|
||||
3. Converts specs to Slate markdown using Widdershins
|
||||
4. Updates `docs/source/includes/` with service documentation
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
In production, this process runs automatically:
|
||||
- CI fetches OpenAPI specs from built service binaries
|
||||
- Widdershins converts to Slate markdown
|
||||
- Middleman builds static HTML
|
||||
- Static files deployed to `docs.{domain}`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Connection refused"**: Ensure services are running on expected ports
|
||||
- **"No OpenAPI spec found"**: Check that services expose `/openapi.json`
|
||||
- **"widdershins not found"**: Run `npm install -g widdershins`
|
||||
- **"bundle not found"**: Install Ruby and run `gem install bundler`
|
||||
@ -38,6 +38,28 @@ cd {{PROJECT_NAME}}
|
||||
| `./scripts/quality.sh` | Run quality checks on all components |
|
||||
| `./scripts/discover.sh` | List all components in the monorepo |
|
||||
|
||||
## API Documentation
|
||||
|
||||
API documentation is automatically generated from OpenAPI specs and deployed to:
|
||||
|
||||
- **Docs**: https://docs.{{DOMAIN}}
|
||||
- **OpenAPI Spec**: Each service exposes `/openapi.json`
|
||||
|
||||
To regenerate docs locally:
|
||||
|
||||
```bash
|
||||
# Start services locally
|
||||
./scripts/dev.sh
|
||||
|
||||
# Generate Slate markdown from OpenAPI specs
|
||||
./docs/scripts/generate-docs.sh http://localhost
|
||||
|
||||
# Preview docs (optional - requires Ruby)
|
||||
cd docs && bundle install && bundle exec middleman serve
|
||||
```
|
||||
|
||||
Documentation is automatically rebuilt on every push to `main`.
|
||||
|
||||
## Adding Components
|
||||
|
||||
Components are added via the rdev API:
|
||||
|
||||
15
internal/adapter/templates/templates/skeleton/docs/.gitignore
vendored
Normal file
15
internal/adapter/templates/templates/skeleton/docs/.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Slate build output
|
||||
build/
|
||||
|
||||
# Ruby/Bundler
|
||||
.bundle/
|
||||
vendor/bundle/
|
||||
Gemfile.lock
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.bak
|
||||
*~
|
||||
|
||||
# Generated includes (except _errors.md)
|
||||
source/includes/_*_spec.json
|
||||
@ -0,0 +1,27 @@
|
||||
# Slate documentation builder
|
||||
# Used by CI to generate static HTML from OpenAPI specs
|
||||
|
||||
FROM ruby:3.2-slim
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
nodejs \
|
||||
npm \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install widdershins globally for OpenAPI to Slate markdown conversion
|
||||
RUN npm install -g widdershins
|
||||
|
||||
WORKDIR /docs
|
||||
|
||||
# Copy Gemfile first for layer caching
|
||||
COPY Gemfile Gemfile.lock* ./
|
||||
RUN bundle install
|
||||
|
||||
# Copy the rest of the docs source
|
||||
COPY . .
|
||||
|
||||
# Build static site
|
||||
CMD ["bundle", "exec", "middleman", "build", "--clean"]
|
||||
@ -0,0 +1,47 @@
|
||||
# Production nginx image for serving Slate documentation
|
||||
# Built by CI after Slate generates static HTML
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx content
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy built static files from Slate
|
||||
COPY build/ /usr/share/nginx/html/
|
||||
|
||||
# Custom nginx config for SPA-style routing
|
||||
RUN cat > /etc/nginx/conf.d/default.conf << 'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
gzip_min_length 1000;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Serve index.html for all routes (SPA fallback)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
14
internal/adapter/templates/templates/skeleton/docs/Gemfile
Normal file
14
internal/adapter/templates/templates/skeleton/docs/Gemfile
Normal file
@ -0,0 +1,14 @@
|
||||
# Slate documentation dependencies
|
||||
source 'https://rubygems.org'
|
||||
|
||||
# Slate uses Middleman for static site generation
|
||||
gem 'middleman', '~> 4.4'
|
||||
gem 'middleman-autoprefixer', '~> 3.0'
|
||||
gem 'middleman-syntax', '~> 3.2'
|
||||
gem 'middleman-sprockets', '~> 4.1'
|
||||
|
||||
# Slate-specific dependencies
|
||||
gem 'rouge', '~> 4.0'
|
||||
gem 'redcarpet', '~> 3.5'
|
||||
gem 'nokogiri', '~> 1.15'
|
||||
gem 'activesupport', '~> 7.0'
|
||||
56
internal/adapter/templates/templates/skeleton/docs/config.rb
Normal file
56
internal/adapter/templates/templates/skeleton/docs/config.rb
Normal file
@ -0,0 +1,56 @@
|
||||
# Slate/Middleman configuration
|
||||
# Based on slatedocs/slate with customizations for monorepo API docs
|
||||
|
||||
# Markdown rendering
|
||||
set :markdown_engine, :redcarpet
|
||||
set :markdown,
|
||||
fenced_code_blocks: true,
|
||||
smartypants: true,
|
||||
disable_indented_code_blocks: true,
|
||||
prettify: true,
|
||||
strikethrough: true,
|
||||
tables: true,
|
||||
with_toc_data: true,
|
||||
no_intra_emphasis: true
|
||||
|
||||
# Syntax highlighting
|
||||
activate :syntax
|
||||
set :haml, { ugly: true }
|
||||
|
||||
# Assets
|
||||
set :css_dir, 'stylesheets'
|
||||
set :js_dir, 'javascripts'
|
||||
set :images_dir, 'images'
|
||||
set :fonts_dir, 'fonts'
|
||||
|
||||
# Build directory
|
||||
set :build_dir, 'build'
|
||||
|
||||
# Relative assets for static hosting
|
||||
activate :relative_assets
|
||||
set :relative_links, true
|
||||
|
||||
# Build-specific configuration
|
||||
configure :build do
|
||||
activate :minify_css
|
||||
activate :minify_javascript, ignore: [/all_.*\.js/]
|
||||
activate :asset_hash
|
||||
end
|
||||
|
||||
# Development helpers
|
||||
helpers do
|
||||
def toc_data(page_content)
|
||||
content = page_content.dup
|
||||
toc = []
|
||||
|
||||
content.scan(/<h([12]).*?id="([^"]+)".*?>(.+?)<\/h\1>/m) do |level, id, text|
|
||||
toc << {
|
||||
level: level.to_i,
|
||||
id: id,
|
||||
text: text.gsub(/<[^>]+>/, '')
|
||||
}
|
||||
end
|
||||
|
||||
toc
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
# Generate Slate documentation from OpenAPI specs
|
||||
#
|
||||
# This script:
|
||||
# 1. Discovers all services with OpenAPI specs
|
||||
# 2. Uses widdershins to convert OpenAPI JSON to Slate markdown
|
||||
# 3. Injects the generated includes into the main index
|
||||
#
|
||||
# Usage: ./docs/scripts/generate-docs.sh [base_url]
|
||||
# base_url: Optional base URL for fetching specs (default: http://localhost)
|
||||
#
|
||||
# The script expects services to be running locally during CI/CD build.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOCS_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PROJECT_ROOT="$(dirname "$DOCS_DIR")"
|
||||
INCLUDES_DIR="$DOCS_DIR/source/includes"
|
||||
|
||||
BASE_URL="${1:-http://localhost}"
|
||||
|
||||
echo "==> Generating API documentation from OpenAPI specs"
|
||||
echo " Base URL: $BASE_URL"
|
||||
echo " Docs dir: $DOCS_DIR"
|
||||
|
||||
# Ensure includes directory exists
|
||||
mkdir -p "$INCLUDES_DIR"
|
||||
|
||||
# Clean old service includes (keep _errors.md)
|
||||
find "$INCLUDES_DIR" -name '_*.md' ! -name '_errors.md' -delete 2>/dev/null || true
|
||||
|
||||
# Track which services we generate docs for
|
||||
SERVICES=()
|
||||
|
||||
# Discover services by looking for OpenAPI spec files or main.go
|
||||
for service_dir in "$PROJECT_ROOT"/services/*/; do
|
||||
[ -d "$service_dir" ] || continue
|
||||
|
||||
service_name=$(basename "$service_dir")
|
||||
[ "$service_name" = ".gitkeep" ] && continue
|
||||
|
||||
echo "==> Processing service: $service_name"
|
||||
|
||||
# Try to fetch OpenAPI spec from running service
|
||||
spec_url="$BASE_URL/api/$service_name/openapi.json"
|
||||
spec_file="$INCLUDES_DIR/_${service_name}_spec.json"
|
||||
|
||||
if curl -sf --connect-timeout 5 "$spec_url" > "$spec_file" 2>/dev/null; then
|
||||
echo " Fetched spec from $spec_url"
|
||||
else
|
||||
# Fallback: check for static spec file
|
||||
if [ -f "$service_dir/openapi.json" ]; then
|
||||
cp "$service_dir/openapi.json" "$spec_file"
|
||||
echo " Using static spec from $service_dir/openapi.json"
|
||||
else
|
||||
echo " WARNING: No OpenAPI spec found for $service_name, skipping"
|
||||
rm -f "$spec_file"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Convert OpenAPI to Slate markdown using widdershins
|
||||
output_file="$INCLUDES_DIR/_${service_name}.md"
|
||||
|
||||
echo " Converting to markdown..."
|
||||
widdershins \
|
||||
--language_tabs 'shell:curl' 'go:Go' \
|
||||
--summary \
|
||||
--omitHeader \
|
||||
--resolve \
|
||||
--shallowSchemas \
|
||||
"$spec_file" \
|
||||
-o "$output_file"
|
||||
|
||||
# Clean up temp spec file
|
||||
rm -f "$spec_file"
|
||||
|
||||
SERVICES+=("$service_name")
|
||||
echo " Generated: $output_file"
|
||||
done
|
||||
|
||||
# Update index.html.md with service includes
|
||||
INDEX_FILE="$DOCS_DIR/source/index.html.md"
|
||||
if [ -f "$INDEX_FILE" ]; then
|
||||
echo "==> Updating index with service includes"
|
||||
|
||||
# Build includes list
|
||||
INCLUDES=""
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
INCLUDES="$INCLUDES - $svc"$'\n'
|
||||
done
|
||||
|
||||
# Update the includes section in frontmatter
|
||||
# This is a simple approach - insert after 'includes:' line
|
||||
if grep -q "^includes:" "$INDEX_FILE"; then
|
||||
# Create temp file with updated includes
|
||||
awk -v services="$INCLUDES" '
|
||||
/^includes:/ {
|
||||
print $0
|
||||
print " - errors"
|
||||
# Add service includes
|
||||
n = split(services, arr, "\n")
|
||||
for (i = 1; i <= n; i++) {
|
||||
if (arr[i] != "") print arr[i]
|
||||
}
|
||||
# Skip existing includes until next frontmatter key or ---
|
||||
while ((getline line) > 0) {
|
||||
if (line ~ /^[a-z_]+:/ || line == "---") {
|
||||
print line
|
||||
break
|
||||
}
|
||||
}
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$INDEX_FILE" > "$INDEX_FILE.tmp" && mv "$INDEX_FILE.tmp" "$INDEX_FILE"
|
||||
fi
|
||||
|
||||
echo " Updated includes: ${SERVICES[*]:-none}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Documentation generation complete"
|
||||
echo " Services documented: ${#SERVICES[@]}"
|
||||
echo ""
|
||||
echo "To build the static site:"
|
||||
echo " cd $DOCS_DIR && bundle exec middleman build"
|
||||
@ -0,0 +1,59 @@
|
||||
# Errors
|
||||
|
||||
The API uses standard HTTP status codes to indicate success or failure.
|
||||
|
||||
## Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable error message"
|
||||
},
|
||||
"meta": {
|
||||
"request_id": "abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Error Codes
|
||||
|
||||
| HTTP Status | Error Code | Description |
|
||||
|-------------|------------|-------------|
|
||||
| 400 | `BAD_REQUEST` | The request body is malformed or missing required fields |
|
||||
| 401 | `UNAUTHORIZED` | Authentication is required but was not provided |
|
||||
| 403 | `FORBIDDEN` | The authenticated user lacks permission for this action |
|
||||
| 404 | `NOT_FOUND` | The requested resource does not exist |
|
||||
| 409 | `CONFLICT` | The request conflicts with existing data (e.g., duplicate) |
|
||||
| 422 | `VALIDATION_ERROR` | The request data failed validation rules |
|
||||
| 429 | `RATE_LIMITED` | Too many requests; slow down |
|
||||
| 500 | `INTERNAL_ERROR` | An unexpected server error occurred |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | The service is temporarily unavailable |
|
||||
|
||||
## Validation Errors
|
||||
|
||||
Validation errors (422) include field-specific details:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Validation failed",
|
||||
"details": {
|
||||
"name": "name is required",
|
||||
"email": "invalid email format"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
When rate limited (429), the response includes headers indicating when you can retry:
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `X-RateLimit-Limit` | Maximum requests per window |
|
||||
| `X-RateLimit-Remaining` | Requests remaining in current window |
|
||||
| `X-RateLimit-Reset` | Unix timestamp when the window resets |
|
||||
| `Retry-After` | Seconds to wait before retrying |
|
||||
@ -0,0 +1,84 @@
|
||||
---
|
||||
title: {{PROJECT_NAME}} API Documentation
|
||||
|
||||
language_tabs:
|
||||
- shell: curl
|
||||
- go: Go
|
||||
|
||||
toc_footers:
|
||||
- <a href='https://{{DOMAIN}}'>{{PROJECT_NAME}}</a>
|
||||
- <a href='https://github.com/slatedocs/slate'>Documentation Powered by Slate</a>
|
||||
|
||||
includes:
|
||||
- errors
|
||||
|
||||
search: true
|
||||
|
||||
code_clipboard: true
|
||||
|
||||
meta:
|
||||
- name: description
|
||||
content: API documentation for {{PROJECT_NAME}}
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
Welcome to the **{{PROJECT_NAME}}** API documentation.
|
||||
|
||||
This documentation covers all services in the {{PROJECT_NAME}} monorepo. Each service provides a REST API with JSON responses.
|
||||
|
||||
## Authentication
|
||||
|
||||
Most endpoints require authentication via Bearer token:
|
||||
|
||||
```shell
|
||||
curl -X GET "https://{{DOMAIN}}/api/service-name/endpoint" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
```go
|
||||
req, _ := http.NewRequest("GET", "https://{{DOMAIN}}/api/service-name/endpoint", nil)
|
||||
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
|
||||
```
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Environment | URL |
|
||||
|-------------|-----|
|
||||
| Production | `https://{{DOMAIN}}` |
|
||||
| Staging | `https://staging.{{DOMAIN}}` |
|
||||
|
||||
## Response Format
|
||||
|
||||
All API responses use a consistent envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"request_id": "abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Error responses include an error object:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "Resource not found"
|
||||
},
|
||||
"meta": {
|
||||
"request_id": "abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# Services
|
||||
|
||||
The following services are available in this project. Click on a service name to jump to its API documentation.
|
||||
|
||||
<!-- SERVICE_INCLUDES_BELOW -->
|
||||
<!-- Auto-generated includes will be inserted here by generate-docs.sh -->
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title><%= current_page.data.title || "API Documentation" %></title>
|
||||
|
||||
<style media="screen">
|
||||
<%= Rouge::Themes::Base16::Monokai.render(:scope => '.highlight') %>
|
||||
</style>
|
||||
<style media="print">
|
||||
* {
|
||||
-webkit-transition: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
<%= stylesheet_link_tag :screen, media: :screen %>
|
||||
<%= stylesheet_link_tag :print, media: :print %>
|
||||
|
||||
<% if current_page.data.search %>
|
||||
<%= javascript_include_tag "all" %>
|
||||
<% else %>
|
||||
<%= javascript_include_tag "all_nosearch" %>
|
||||
<% end %>
|
||||
|
||||
<% if current_page.data.code_clipboard %>
|
||||
<script>
|
||||
$(function() { setupCodeCopy(); });
|
||||
</script>
|
||||
<% end %>
|
||||
</head>
|
||||
|
||||
<body class="<%= page_classes %>" data-languages="<%=h language_tabs.map{ |lang| lang.is_a?(Hash) ? lang.keys.first : lang }.to_json %>">
|
||||
<a href="#" id="nav-button">
|
||||
<span>
|
||||
NAV
|
||||
<%= image_tag('navbar.png') %>
|
||||
</span>
|
||||
</a>
|
||||
<div class="toc-wrapper">
|
||||
<% if language_tabs.any? %>
|
||||
<div class="lang-selector">
|
||||
<% language_tabs.each do |lang| %>
|
||||
<% if lang.is_a? Hash %>
|
||||
<a href="#" data-language-name="<%= lang.keys.first %>"><%= lang.values.first %></a>
|
||||
<% else %>
|
||||
<a href="#" data-language-name="<%= lang %>"><%= lang %></a>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if current_page.data.search %>
|
||||
<div class="search">
|
||||
<input type="text" class="search" id="input-search" placeholder="Search">
|
||||
</div>
|
||||
<ul class="search-results"></ul>
|
||||
<% end %>
|
||||
<ul id="toc" class="toc-list-h1">
|
||||
<% toc_data(page_content).each do |h1| %>
|
||||
<li>
|
||||
<a href="#<%= h1[:id] %>" class="toc-h1 toc-link" data-title="<%= h1[:content] %>"><%= h1[:content] %></a>
|
||||
<% if h1[:children].length > 0 %>
|
||||
<ul class="toc-list-h2">
|
||||
<% h1[:children].each do |h2| %>
|
||||
<li>
|
||||
<a href="#<%= h2[:id] %>" class="toc-h2 toc-link" data-title="<%= h2[:content] %>"><%= h2[:content] %></a>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% if current_page.data.toc_footers %>
|
||||
<ul class="toc-footer">
|
||||
<% current_page.data.toc_footers.each do |footer| %>
|
||||
<li><%= footer %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="page-wrapper">
|
||||
<div class="dark-box"></div>
|
||||
<div class="content">
|
||||
<%= page_content %>
|
||||
</div>
|
||||
<div class="dark-box">
|
||||
<% if language_tabs.any? %>
|
||||
<div class="lang-selector">
|
||||
<% language_tabs.each do |lang| %>
|
||||
<% if lang.is_a? Hash %>
|
||||
<a href="#" data-language-name="<%= lang.keys.first %>"><%= lang.values.first %></a>
|
||||
<% else %>
|
||||
<a href="#" data-language-name="<%= lang %>"><%= lang %></a>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,34 @@
|
||||
// Custom variables for Slate theming
|
||||
// Override Slate defaults to match project branding
|
||||
|
||||
// Colors - dark theme
|
||||
$nav-bg: #1e1e2e !default;
|
||||
$nav-text: #cdd6f4 !default;
|
||||
$nav-active-bg: #313244 !default;
|
||||
$nav-active-text: #f5c2e7 !default;
|
||||
$nav-hover-bg: #45475a !default;
|
||||
|
||||
$content-bg: #1e1e2e !default;
|
||||
$content-text: #cdd6f4 !default;
|
||||
$code-bg: #181825 !default;
|
||||
$code-text: #cdd6f4 !default;
|
||||
|
||||
$border-color: #45475a !default;
|
||||
|
||||
// Code highlighting (catppuccin-mocha inspired)
|
||||
$code-annotation-bg: #313244 !default;
|
||||
$code-annotation-text: #a6adc8 !default;
|
||||
|
||||
// Fonts
|
||||
$font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !default;
|
||||
$code-font-family: "SF Mono", Consolas, Monaco, "Andale Mono", monospace !default;
|
||||
|
||||
// Sizes
|
||||
$nav-width: 230px !default;
|
||||
$max-content-width: 800px !default;
|
||||
|
||||
// Language tabs
|
||||
$lang-select-bg: #11111b !default;
|
||||
$lang-select-text: #cdd6f4 !default;
|
||||
$lang-select-active-bg: #313244 !default;
|
||||
$lang-select-active-text: #f5c2e7 !default;
|
||||
@ -0,0 +1,158 @@
|
||||
// Slate documentation styles
|
||||
// Imports Slate defaults and applies custom variables
|
||||
|
||||
@import 'variables';
|
||||
|
||||
// Base Slate styles
|
||||
@import 'normalize';
|
||||
@import 'icon-font';
|
||||
|
||||
// Custom overrides
|
||||
body {
|
||||
background-color: $content-bg;
|
||||
color: $content-text;
|
||||
font-family: $font-family;
|
||||
}
|
||||
|
||||
// Navigation
|
||||
.toc-wrapper {
|
||||
background-color: $nav-bg;
|
||||
width: $nav-width;
|
||||
|
||||
.toc-link {
|
||||
color: $nav-text;
|
||||
|
||||
&.active {
|
||||
background-color: $nav-active-bg;
|
||||
color: $nav-active-text;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $nav-hover-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.toc-h2 {
|
||||
padding-left: 25px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// Code blocks
|
||||
pre {
|
||||
background-color: $code-bg;
|
||||
|
||||
code {
|
||||
color: $code-text;
|
||||
font-family: $code-font-family;
|
||||
}
|
||||
}
|
||||
|
||||
// Inline code
|
||||
code {
|
||||
background-color: $code-bg;
|
||||
color: $code-text;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: $code-font-family;
|
||||
}
|
||||
|
||||
// Language tabs
|
||||
.lang-selector {
|
||||
background-color: $lang-select-bg;
|
||||
|
||||
a {
|
||||
color: $lang-select-text;
|
||||
|
||||
&.active {
|
||||
background-color: $lang-select-active-bg;
|
||||
color: $lang-select-active-text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tables
|
||||
table {
|
||||
margin-bottom: 1em;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
th, td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: $code-bg;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Headers
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: $content-text;
|
||||
}
|
||||
|
||||
h1 {
|
||||
border-bottom: 1px solid $border-color;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
// Links
|
||||
a {
|
||||
color: $nav-active-text;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Content area
|
||||
.page-wrapper {
|
||||
margin-left: $nav-width;
|
||||
max-width: $max-content-width;
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
// Code annotations
|
||||
.code-annotation {
|
||||
background-color: $code-annotation-bg;
|
||||
color: $code-annotation-text;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// Search
|
||||
.search {
|
||||
input {
|
||||
background-color: $code-bg;
|
||||
border: 1px solid $border-color;
|
||||
color: $content-text;
|
||||
|
||||
&::placeholder {
|
||||
color: $code-annotation-text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 930px) {
|
||||
.toc-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@ -292,8 +292,13 @@ func (a *App) ListenAddr() string {
|
||||
return a.serverConfig.Addr()
|
||||
}
|
||||
|
||||
// EnableDocs adds /docs and /openapi.json endpoints to the application.
|
||||
// It mounts the Scalar UI at /docs and the OpenAPI JSON spec at /openapi.json.
|
||||
// EnableDocs adds OpenAPI endpoints to the application.
|
||||
// It mounts:
|
||||
// - /openapi.json: The raw OpenAPI spec (consumed by CI for Slate doc generation)
|
||||
// - /docs: Redirects to the Slate documentation site at docs.{domain}
|
||||
//
|
||||
// During CI builds, Widdershins converts /openapi.json to Slate markdown,
|
||||
// which is then built into static HTML and deployed to docs.{domain}.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
@ -302,7 +307,7 @@ func (a *App) ListenAddr() string {
|
||||
// application.EnableDocs(spec)
|
||||
func (a *App) EnableDocs(spec *openapi.OpenAPISpec) {
|
||||
openapi.Mount(a.router, spec)
|
||||
a.logger.Info("API documentation enabled", "docs", "/docs", "spec", "/openapi.json")
|
||||
a.logger.Info("API documentation enabled", "spec", "/openapi.json", "docs_redirect", "/docs")
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler, allowing App to be used in tests.
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
scalargo "github.com/bdpiprava/scalar-go"
|
||||
)
|
||||
|
||||
// Mount registers /docs and /openapi.json endpoints on the router.
|
||||
// Mount registers the /openapi.json endpoint on the router.
|
||||
// The OpenAPI spec is consumed by CI to generate Slate documentation
|
||||
// which is served at docs.{domain}. No interactive UI is mounted here.
|
||||
func Mount(r chi.Router, spec *OpenAPISpec) {
|
||||
// Serve OpenAPI JSON
|
||||
// Serve OpenAPI JSON spec (consumed by Widdershins during doc generation)
|
||||
r.Get("/openapi.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
@ -24,26 +24,19 @@ func Mount(r chi.Router, spec *OpenAPISpec) {
|
||||
_, _ = w.Write(specBytes)
|
||||
})
|
||||
|
||||
// Serve Scalar docs UI
|
||||
// /docs redirects to the Slate documentation site
|
||||
r.Get("/docs", func(w http.ResponseWriter, r *http.Request) {
|
||||
scheme := "http"
|
||||
// Construct docs URL from current host
|
||||
// e.g., api.slug.threesix.ai -> docs.slug.threesix.ai
|
||||
scheme := "https"
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
} else if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
specURL := fmt.Sprintf("%s://%s/openapi.json", scheme, r.Host)
|
||||
|
||||
html, err := scalargo.NewV2(
|
||||
scalargo.WithSpecURL(specURL),
|
||||
scalargo.WithDarkMode(),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprint(w, html)
|
||||
// Extract the base domain (remove service prefix if present)
|
||||
host := r.Host
|
||||
docsURL := scheme + "://docs." + host
|
||||
|
||||
http.Redirect(w, r, docsURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
@ -127,6 +127,9 @@ type CreateProjectResult struct {
|
||||
Domain string
|
||||
URL string
|
||||
|
||||
// API documentation URL (docs.{slug}.{domain})
|
||||
DocsURL string
|
||||
|
||||
// All domains associated with the project
|
||||
Domains []*domain.ProjectDomain
|
||||
|
||||
|
||||
@ -64,22 +64,25 @@ func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProje
|
||||
// 5. Create custom subdomain if requested
|
||||
s.createCustomDNS(ctx, req, projectID, result)
|
||||
|
||||
// 6. Activate CI (Woodpecker) - Before seeding so the webhook is installed
|
||||
// 6. Create docs subdomain for API documentation (docs.{slug}.{domain})
|
||||
s.createDocsDNS(ctx, slug, projectID, result)
|
||||
|
||||
// 7. Activate CI (Woodpecker) - Before seeding so the webhook is installed
|
||||
ciActivated := s.activateCI(ctx, result)
|
||||
|
||||
// 7. Seed repository with template
|
||||
// 8. Seed repository with template
|
||||
templateSeeded := s.seedTemplate(ctx, req, result)
|
||||
|
||||
// 8. Provision database and cache
|
||||
// 9. Provision database and cache
|
||||
s.provisionResources(ctx, result)
|
||||
|
||||
// 9. Create initial K8s deployment (before triggering CI build)
|
||||
// 10. Create initial K8s deployment (before triggering CI build)
|
||||
// This ensures the deployment exists for `kubectl set image` in CI pipeline
|
||||
if templateSeeded {
|
||||
s.createInitialDeployment(ctx, req, result)
|
||||
}
|
||||
|
||||
// 10. Trigger initial CI build if both CI and template are ready
|
||||
// 11. Trigger initial CI build if both CI and template are ready
|
||||
if ciActivated && templateSeeded && s.ciProvider != nil {
|
||||
pipelineNum, err := s.ciProvider.TriggerBuild(ctx, result.GitRepoOwner, result.GitRepoName, "main")
|
||||
if err != nil {
|
||||
@ -266,6 +269,51 @@ func (s *ProjectInfraService) createCustomDNS(ctx context.Context, req CreatePro
|
||||
}
|
||||
}
|
||||
|
||||
// createDocsDNS creates DNS record for docs.{slug}.{defaultDomain}.
|
||||
// This enables Slate API documentation to be served at docs.{domain}.
|
||||
func (s *ProjectInfraService) createDocsDNS(ctx context.Context, slug, projectID string, result *CreateProjectResult) {
|
||||
if s.dns == nil {
|
||||
return // DNS not configured, skip silently (not critical)
|
||||
}
|
||||
|
||||
log := logging.FromContext(ctx).WithService("project_infra")
|
||||
docsSubdomain := "docs." + slug
|
||||
docsDomain := docsSubdomain + "." + s.defaultDomain
|
||||
|
||||
dnsRecord, err := s.dns.CreateRecord(ctx, domain.DNSRecord{
|
||||
Type: "A",
|
||||
Name: docsSubdomain,
|
||||
Content: s.clusterIP,
|
||||
TTL: 1,
|
||||
Proxied: false,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("failed to create docs DNS record", logging.FieldError, err, "domain", docsDomain)
|
||||
result.NextSteps = append(result.NextSteps, "Create docs DNS manually: "+docsDomain+" → "+s.clusterIP)
|
||||
return
|
||||
}
|
||||
|
||||
// Store in project_domains table
|
||||
if s.domainRepo != nil {
|
||||
pd := &domain.ProjectDomain{
|
||||
ProjectID: projectID,
|
||||
Domain: docsDomain,
|
||||
Type: domain.DomainTypeAlias, // docs subdomain is an alias
|
||||
DNSRecordID: dnsRecord.ID,
|
||||
DNSRecordType: "A",
|
||||
Verified: true,
|
||||
}
|
||||
if err := s.domainRepo.Create(ctx, pd); err != nil {
|
||||
log.Warn("failed to store docs domain", logging.FieldError, err)
|
||||
} else {
|
||||
result.Domains = append(result.Domains, pd)
|
||||
}
|
||||
}
|
||||
|
||||
result.DocsURL = "https://" + docsDomain
|
||||
log.Info("docs DNS created", "domain", docsDomain)
|
||||
}
|
||||
|
||||
func (s *ProjectInfraService) activateCI(ctx context.Context, result *CreateProjectResult) bool {
|
||||
log := logging.FromContext(ctx).WithService("project_infra")
|
||||
if s.ciProvider == nil {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user