feat: add Slate documentation templates to skeleton
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:
jordan 2026-02-07 16:06:36 -07:00
parent f64377116a
commit af91bad0ff
19 changed files with 897 additions and 31 deletions

View File

@ -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/*`.

View File

@ -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()

View File

@ -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`

View File

@ -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:

View 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

View File

@ -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"]

View File

@ -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;"]

View 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'

View 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

View File

@ -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"

View File

@ -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 |

View File

@ -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 -->

View File

@ -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>

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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.

View File

@ -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)
})
}

View File

@ -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

View File

@ -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 {