initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Build artefacts
|
||||||
|
/bin/
|
||||||
|
/frontend/dist/
|
||||||
|
/frontend/node_modules/
|
||||||
|
/backend/webui/dist/*
|
||||||
|
!/backend/webui/dist/index.html
|
||||||
|
|
||||||
|
# Flat-file store: the seed document is tracked, generated fixtures are not
|
||||||
|
/data/docs/*
|
||||||
|
!/data/docs/go-doc.md
|
||||||
|
/data/comments/
|
||||||
|
!data/comments/.gitkeep
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS noise
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs and scratch
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
/tmp/
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Production build for go-doc.
|
||||||
|
# Build context must be the workspace root (/home/dnoulet/go).
|
||||||
|
# Documents under go-doc/data/docs are copied into the image at /data/docs.
|
||||||
|
# Persistence must mount only /data/comments so this tree stays visible.
|
||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
|
||||||
|
WORKDIR /src/frontend
|
||||||
|
COPY go-doc/frontend/package.json go-doc/frontend/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY go-doc/frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.27-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go-doc/backend/go.mod go-doc/backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY go-doc/backend/ backend/
|
||||||
|
COPY --from=frontend /src/frontend/dist/ backend/webui/dist/
|
||||||
|
COPY go-doc/data/docs /staged/docs
|
||||||
|
RUN chmod -R a+rX /staged/docs && mkdir -p /empty/comments
|
||||||
|
RUN cd backend && CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /out/godoc .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
COPY --from=builder /out/godoc /godoc
|
||||||
|
COPY --from=builder /staged/docs /data/docs
|
||||||
|
COPY --from=builder /empty/comments /data/comments
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENV GO_DOC_DATA_DIR=/data
|
||||||
|
|
||||||
|
ENTRYPOINT ["/godoc", "-addr", "0.0.0.0:8080"]
|
||||||
288
Makefile
Normal file
288
Makefile
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
# =============================================================================
|
||||||
|
# go-doc — documentation & audience feedback platform
|
||||||
|
#
|
||||||
|
# make help list every target
|
||||||
|
# make dev run infrastructure + backend + frontend together
|
||||||
|
# make test run unit, integration and BDD suites
|
||||||
|
# make build embed the React bundle into the portable Go binary
|
||||||
|
# make docker build the all-in-one image
|
||||||
|
# make docker-push build and push the image to the private registry
|
||||||
|
# make docker-run run the image on :8080
|
||||||
|
# make helm-deploy-development deploy to development
|
||||||
|
# make helm-deploy-production deploy to production
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
.SHELLFLAGS := -eu -o pipefail -c
|
||||||
|
|
||||||
|
GO ?= go
|
||||||
|
NPM ?= npm
|
||||||
|
COMPOSE ?= docker compose
|
||||||
|
HELM ?= helm
|
||||||
|
|
||||||
|
ROOT := $(shell pwd)
|
||||||
|
WORKSPACE ?= $(dir $(ROOT))
|
||||||
|
BACKEND_DIR := $(ROOT)/backend
|
||||||
|
FRONTEND_DIR := $(ROOT)/frontend
|
||||||
|
DATA_DIR := $(ROOT)/data
|
||||||
|
WEBUI_DIST := $(BACKEND_DIR)/webui/dist
|
||||||
|
|
||||||
|
BINARY := $(ROOT)/bin/godoc
|
||||||
|
BACKEND_ADDR ?= 0.0.0.0:8080
|
||||||
|
HOST_ADDR ?= 127.0.0.1:8080
|
||||||
|
VITE_PORT ?= 5173
|
||||||
|
|
||||||
|
# Docker/Registry
|
||||||
|
IMAGE ?= go-doc
|
||||||
|
TAG ?= latest
|
||||||
|
REGISTRY_IMAGE ?= nexus.urpi.be/docker-hosted/go-doc
|
||||||
|
|
||||||
|
# Helm
|
||||||
|
HELM_CHART := $(ROOT)/helm/go-doc
|
||||||
|
HELM_RELEASE ?= go-doc
|
||||||
|
HELM_NAMESPACE ?= go-doc
|
||||||
|
KUBECONFIG ?= $(HOME)/.kube/config
|
||||||
|
GO_LOOSE_CA_SECRET ?= local-ca
|
||||||
|
|
||||||
|
# Go Loose credentials passed via environment or .env
|
||||||
|
GO_LOOSE_SET = $(if $(GO_LOOSE_CLIENT_ID),--set-string goLoose.clientID=$(GO_LOOSE_CLIENT_ID)) \
|
||||||
|
$(if $(GO_LOOSE_CLIENT_SECRET),--set-string goLoose.clientSecret=$(GO_LOOSE_CLIENT_SECRET)) \
|
||||||
|
$(if $(GO_LOOSE_CA_SECRET),--set-string goLoose.ca.existingSecret=$(GO_LOOSE_CA_SECRET))
|
||||||
|
|
||||||
|
# Parent directory that holds the go-* projects to document. Override with
|
||||||
|
# WORKSPACE=/path/to/projects when the projects live elsewhere.
|
||||||
|
DOCS_WORKSPACE ?= $(patsubst %/,%,$(WORKSPACE))
|
||||||
|
DOCGEN_INTERVAL ?= 5s
|
||||||
|
|
||||||
|
DOCS_COUNT ?= 120
|
||||||
|
COMMENTS_PER_DOC ?= 25
|
||||||
|
SEED_WORKERS ?= 8
|
||||||
|
|
||||||
|
GO_SOURCES := $(shell find $(BACKEND_DIR) -name '*.go' -type f 2>/dev/null)
|
||||||
|
FRONTEND_SOURCES := $(shell find $(FRONTEND_DIR)/src -type f 2>/dev/null)
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.PHONY: help infra-up infra-down infra-logs backend-run frontend-run dev test \
|
||||||
|
test-unit test-integration test-bdd test-race build build-frontend \
|
||||||
|
build-backend fmt vet tidy seed seed-perf clean distclean run docker-build \
|
||||||
|
docker docker-push docker-run helm-lint helm-deploy-development \
|
||||||
|
helm-deploy-production helm-down-development helm-down-production \
|
||||||
|
docs-sync docs-watch docs-dry-run docs-list
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Help
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
help: ## Show this help
|
||||||
|
@printf '\n\033[1mgo-doc\033[0m — available targets\n\n'
|
||||||
|
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
|
| sort \
|
||||||
|
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||||
|
@printf '\n'
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Infrastructure
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
infra-up: ## Start local development containers detached
|
||||||
|
@if $(COMPOSE) version >/dev/null 2>&1; then \
|
||||||
|
$(COMPOSE) up -d --remove-orphans; \
|
||||||
|
else \
|
||||||
|
echo "error: '$(COMPOSE)' is unavailable. Install Docker Compose v2." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
infra-down: ## Tear down infrastructure and remove volumes
|
||||||
|
@if $(COMPOSE) version >/dev/null 2>&1; then \
|
||||||
|
$(COMPOSE) down --volumes --remove-orphans; \
|
||||||
|
else \
|
||||||
|
echo "docker compose unavailable; nothing to tear down"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
infra-logs: ## Tail infrastructure logs
|
||||||
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
|
docker-build: ## Build the container images
|
||||||
|
$(COMPOSE) build
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Run
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
backend-run: ## Run the Go API natively on 0.0.0.0:8080
|
||||||
|
@mkdir -p $(DATA_DIR)
|
||||||
|
@echo "→ backend http://$(HOST_ADDR)"
|
||||||
|
@cd $(BACKEND_DIR) && GO_DOC_DATA_DIR=$(DATA_DIR) $(GO) run main.go -addr $(BACKEND_ADDR)
|
||||||
|
|
||||||
|
frontend-run: ## Run the Vite dev server on port 5173
|
||||||
|
@echo "→ frontend http://$(VITE_PORT)"
|
||||||
|
@cd $(FRONTEND_DIR) && $(NPM) run dev -- --port $(VITE_PORT)
|
||||||
|
|
||||||
|
dev: ## Run backend, frontend and infrastructure concurrently
|
||||||
|
@$(MAKE) --no-print-directory infra-up
|
||||||
|
@echo "→ backend http://$(HOST_ADDR)"
|
||||||
|
@echo "→ frontend http://$(VITE_PORT) (Ctrl-C stops everything)"
|
||||||
|
@trap 'echo; echo "→ stopping dev stack"; kill 0' EXIT INT TERM; \
|
||||||
|
( cd $(BACKEND_DIR) && GO_DOC_DATA_DIR=$(DATA_DIR) $(GO) run main.go -addr $(BACKEND_ADDR) ) & \
|
||||||
|
( cd $(FRONTEND_DIR) && $(NPM) run dev -- --port $(VITE_PORT) ) & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
run: build ## Build the single-file binary and run it
|
||||||
|
@echo "→ serving SPA + API on http://$(HOST_ADDR)"
|
||||||
|
@$(BINARY) -addr $(BACKEND_ADDR) -data $(DATA_DIR)
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Test
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
test: fmt vet ## Run every suite: unit, integration, BDD, race
|
||||||
|
@echo "→ go test ./... -count=1"
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) test ./... -count=1
|
||||||
|
@echo "→ race detector on the storage engine and API"
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) test ./storage/... ./tests/... -race -count=1
|
||||||
|
@echo "✓ all suites passed"
|
||||||
|
|
||||||
|
test-unit: ## Run storage engine unit tests only
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) test ./storage/... -count=1 -v
|
||||||
|
|
||||||
|
test-integration: ## Run end-to-end API tests against the real filesystem
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) test ./tests/ -run Integration -count=1 -v
|
||||||
|
|
||||||
|
test-bdd: ## Run the BDD feature suites
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) test ./tests/bdd/... -count=1 -v
|
||||||
|
|
||||||
|
test-race: ## Run all tests under the race detector
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) test ./... -race -count=1
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Build
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
build: build-frontend build-backend ## Build the SPA and embed it in the Go binary
|
||||||
|
@echo "✓ $(BINARY) serves both the SPA and /api"
|
||||||
|
|
||||||
|
build-frontend: $(FRONTEND_SOURCES) ## Compile the React bundle into frontend/dist
|
||||||
|
@cd $(FRONTEND_DIR) && $(NPM) install --no-audit --no-fund
|
||||||
|
@cd $(FRONTEND_DIR) && $(NPM) run build
|
||||||
|
|
||||||
|
build-backend: $(GO_SOURCES) ## Stage assets into webui/dist and compile the binary
|
||||||
|
@test -f $(FRONTEND_DIR)/dist/index.html || { \
|
||||||
|
echo "error: frontend/dist/index.html missing — run 'make build-frontend' first" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
@mkdir -p $(WEBUI_DIST) $(dir $(BINARY))
|
||||||
|
@rm -rf $(WEBUI_DIST)
|
||||||
|
@mkdir -p $(WEBUI_DIST)
|
||||||
|
@cp -r $(FRONTEND_DIR)/dist/. $(WEBUI_DIST)/
|
||||||
|
@echo "→ embedded assets: $$(find $(WEBUI_DIST) -type f | wc -l) files"
|
||||||
|
@cd $(BACKEND_DIR) && CGO_ENABLED=0 $(GO) build -trimpath -ldflags '-s -w' -o $(BINARY) .
|
||||||
|
@echo "✓ built $(BINARY) ($$(du -h $(BINARY) | cut -f1))"
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Quality
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
fmt: ## gofmt the backend
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) fmt ./... >/dev/null
|
||||||
|
@echo "✓ gofmt"
|
||||||
|
|
||||||
|
vet: ## go vet the backend
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) vet ./...
|
||||||
|
@echo "✓ go vet"
|
||||||
|
|
||||||
|
tidy: ## Tidy go.mod
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) mod tidy
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Documentation generation
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Every go-* project under WORKSPACE is analysed and imported into data/docs.
|
||||||
|
## Generated pages are wrapped in <!-- go-doc:generated ---> markers, so a
|
||||||
|
## re-sync replaces only the machine-written part and keeps anything you wrote
|
||||||
|
## outside the markers. Pass DOCGEN_ARGS to reach the CLI directly, e.g.
|
||||||
|
## make docs-sync DOCGEN_ARGS="-apps go-tell -force"
|
||||||
|
|
||||||
|
docs-sync: ## Analyse every go-* project under WORKSPACE and import its docs (DOCGEN_ARGS)
|
||||||
|
@mkdir -p $(DATA_DIR)/docs
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) run ./scripts/docgen \
|
||||||
|
-root $(DOCS_WORKSPACE) -data $(DATA_DIR) $(DOCGEN_ARGS)
|
||||||
|
|
||||||
|
docs-watch: ## Keep importing documentation as projects change (Ctrl-C to stop)
|
||||||
|
@mkdir -p $(DATA_DIR)/docs
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) run ./scripts/docgen \
|
||||||
|
-root $(DOCS_WORKSPACE) -data $(DATA_DIR) -watch -interval $(DOCGEN_INTERVAL) $(DOCGEN_ARGS)
|
||||||
|
|
||||||
|
docs-dry-run: ## Analyse and render without writing anything
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) run ./scripts/docgen \
|
||||||
|
-root $(DOCS_WORKSPACE) -data $(DATA_DIR) -dry-run $(DOCGEN_ARGS)
|
||||||
|
|
||||||
|
docs-list: ## List the go-* projects that a sync would import
|
||||||
|
@printf 'workspace: %s\n\n' '$(DOCS_WORKSPACE)'
|
||||||
|
@ls -1 $(DOCS_WORKSPACE) 2>/dev/null | grep '^go-' | grep -v '^go-doc$$' | sed 's/^/ /'
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Data
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
seed: ## Seed 120 generated documents + thousands of comments (DOCS_COUNT, COMMENTS_PER_DOC)
|
||||||
|
@cd $(BACKEND_DIR) && $(GO) run ./scripts/seed_perf_data.go \
|
||||||
|
-data $(DATA_DIR) -docs $(DOCS_COUNT) -comments $(COMMENTS_PER_DOC) -workers $(SEED_WORKERS)
|
||||||
|
|
||||||
|
seed-perf: seed ## Alias for seed
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Clean
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
clean: ## Remove build artefacts
|
||||||
|
@rm -rf $(ROOT)/bin $(FRONTEND_DIR)/dist
|
||||||
|
@echo "✓ cleaned build artefacts (backend/webui/dist left intact)"
|
||||||
|
|
||||||
|
distclean: clean ## Also restore the placeholder frontend bundle
|
||||||
|
@rm -rf $(WEBUI_DIST)
|
||||||
|
@mkdir -p $(WEBUI_DIST)
|
||||||
|
@cp $(BACKEND_DIR)/webui/dist.placeholder/index.html $(WEBUI_DIST)/index.html
|
||||||
|
@echo "✓ webui/dist reset to the placeholder bundle"
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Docker
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
docker: ## Build the all-in-one image
|
||||||
|
docker build -t $(IMAGE):$(TAG) -f $(BACKEND_DIR)/Dockerfile .
|
||||||
|
|
||||||
|
docker-push: ## Build and push the image to the private registry
|
||||||
|
docker tag $(IMAGE):$(TAG) $(REGISTRY_IMAGE):$(TAG)
|
||||||
|
docker push $(REGISTRY_IMAGE):$(TAG)
|
||||||
|
|
||||||
|
docker-run: ## Run the image on :8080
|
||||||
|
docker run --rm -p 8080:8080 --env-file $(ROOT)/.env $(IMAGE):$(TAG)
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## Helm
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
helm-lint: ## Lint the helm chart
|
||||||
|
$(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-dev.yaml
|
||||||
|
$(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-prd.yaml
|
||||||
|
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \
|
||||||
|
-f $(HELM_CHART)/values-dev.yaml >/dev/null
|
||||||
|
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \
|
||||||
|
-f $(HELM_CHART)/values-prd.yaml \
|
||||||
|
--set-string goLoose.clientID=lint --set-string goLoose.clientSecret=lint >/dev/null
|
||||||
|
|
||||||
|
# Go Loose credentials are never stored in the values files. Pass them on the
|
||||||
|
# command line, from a secret store, or with --set-file:
|
||||||
|
# make helm-deploy-production \
|
||||||
|
# --set goLoose.clientID=$(GO_LOOSE_CLIENT_ID) \
|
||||||
|
# --set goLoose.clientSecret=$(GO_LOOSE_CLIENT_SECRET) \
|
||||||
|
# --set goLoose.ca.existingSecret=local-ca
|
||||||
|
helm-deploy-development: ## Deploy to development
|
||||||
|
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
||||||
|
--namespace $(HELM_NAMESPACE)-development --create-namespace \
|
||||||
|
-f $(HELM_CHART)/values-dev.yaml \
|
||||||
|
--rollback-on-failure --wait --timeout 10m
|
||||||
|
|
||||||
|
helm-deploy-production: ## Deploy to production
|
||||||
|
set -a; [ ! -f $(ROOT)/.env ] || . $(ROOT)/.env; set +a; \
|
||||||
|
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
||||||
|
--namespace go-insane-prd --create-namespace \
|
||||||
|
-f $(HELM_CHART)/values-prd.yaml \
|
||||||
|
$(GO_LOOSE_SET) \
|
||||||
|
--rollback-on-failure --wait --timeout 10m
|
||||||
|
|
||||||
|
helm-down-development: ## Tear down development release
|
||||||
|
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development
|
||||||
|
|
||||||
|
helm-down-production: ## Tear down production release
|
||||||
|
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production
|
||||||
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Development image for the Go backend. `docker-compose up` bind-mounts the
|
||||||
|
# source and runs `go run main.go`, so this image only needs the toolchain.
|
||||||
|
FROM golang:1.27-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata wget
|
||||||
|
|
||||||
|
ENV CGO_ENABLED=0 \
|
||||||
|
GOFLAGS=-mod=mod
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
# Warm the module cache before the bind mount replaces the working directory.
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download || true
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["go", "run", "main.go", "-addr", "0.0.0.0:8080"]
|
||||||
95
backend/auth/auth.go
Normal file
95
backend/auth/auth.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go-doc/go-loose/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents an authenticated user from go-loose
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
DisplayName string
|
||||||
|
AvatarURL string
|
||||||
|
TenantID string
|
||||||
|
TenantSlug string
|
||||||
|
ApplicationID string
|
||||||
|
Application string
|
||||||
|
Role string
|
||||||
|
Permissions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) Can(permission string) bool {
|
||||||
|
for _, granted := range u.Permissions {
|
||||||
|
if granted == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) IsAdmin() bool {
|
||||||
|
return u.Role == "admin" || u.Role == "owner" || u.Can("console.read")
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const userContextKey = contextKey("go-doc-user")
|
||||||
|
|
||||||
|
func WithUser(ctx context.Context, user User) context.Context {
|
||||||
|
return context.WithValue(ctx, userContextKey, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserFromContext(ctx context.Context) (User, bool) {
|
||||||
|
user, ok := ctx.Value(userContextKey).(User)
|
||||||
|
return user, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func TenantIDFromContext(ctx context.Context) (string, bool) {
|
||||||
|
user, ok := UserFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return user.TenantID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserIDFromContext(ctx context.Context) (string, bool) {
|
||||||
|
user, ok := UserFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return user.ID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertUser(looseUser client.User) User {
|
||||||
|
return User{
|
||||||
|
ID: looseUser.ID,
|
||||||
|
Email: looseUser.Email,
|
||||||
|
DisplayName: looseUser.DisplayName,
|
||||||
|
AvatarURL: looseUser.AvatarURL,
|
||||||
|
TenantID: looseUser.TenantID,
|
||||||
|
TenantSlug: looseUser.TenantSlug,
|
||||||
|
ApplicationID: looseUser.ApplicationID,
|
||||||
|
Application: looseUser.Application,
|
||||||
|
Role: looseUser.Role,
|
||||||
|
Permissions: looseUser.Permissions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BrowserAuthMiddleware(browserAuth *client.BrowserAuth) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
if browserAuth == nil {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
browserAuth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if user, ok := client.UserFromContext(r.Context()); ok {
|
||||||
|
r = r.WithContext(WithUser(r.Context(), ConvertUser(user)))
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
728
backend/docgen/docgen.go
Normal file
728
backend/docgen/docgen.go
Normal file
|
|
@ -0,0 +1,728 @@
|
||||||
|
// Package docgen analyses a Go project and renders audience-segmented
|
||||||
|
// documentation for go-doc.
|
||||||
|
//
|
||||||
|
// The pipeline is deliberately mechanical: Analyze walks a project tree and
|
||||||
|
// extracts facts that can be read off the filesystem (module metadata, HTTP
|
||||||
|
// routes, CLI flags, environment variables, Make targets, container services,
|
||||||
|
// Helm charts, existing Markdown, language breakdown), and Render turns those
|
||||||
|
// facts into the same three-audience layout go-doc uses natively:
|
||||||
|
//
|
||||||
|
// ## For Users what the thing does and how to use it
|
||||||
|
// ## For Operators running, configuring and deploying it
|
||||||
|
// ## For Developers contracts, layout and dependencies
|
||||||
|
//
|
||||||
|
// Import wraps the rendered page in generated markers so re-running the tool
|
||||||
|
// refreshes only the machine-written region and never clobbers prose a human
|
||||||
|
// added outside it.
|
||||||
|
package docgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxReadBytes caps how much of any single file is read into memory.
|
||||||
|
const maxReadBytes = 2 << 20
|
||||||
|
|
||||||
|
// maxWalkFiles caps the walk so a mis-pointed root cannot spin forever.
|
||||||
|
const maxWalkFiles = 200000
|
||||||
|
|
||||||
|
// Route is one HTTP endpoint discovered in the source.
|
||||||
|
type Route struct {
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
Handler string
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the route the way it is registered.
|
||||||
|
func (r Route) String() string {
|
||||||
|
if r.Method == "" {
|
||||||
|
return r.Path
|
||||||
|
}
|
||||||
|
return r.Method + " " + r.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortKey orders routes by path, then method, so output is stable.
|
||||||
|
func (r Route) SortKey() string { return r.Path + "\x00" + r.Method + "\x00" + r.Handler }
|
||||||
|
|
||||||
|
// Flag is a command-line flag declared with the flag package.
|
||||||
|
type Flag struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Default string
|
||||||
|
Usage string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvVar is an environment variable the code reads, plus a note when it was
|
||||||
|
// discovered in a .env.example file rather than in source.
|
||||||
|
type EnvVar struct {
|
||||||
|
Name string
|
||||||
|
Example string
|
||||||
|
FromEnv bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency is one requirement line from a go.mod.
|
||||||
|
type Dependency struct {
|
||||||
|
Path string
|
||||||
|
Version string
|
||||||
|
Indirect bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module is one go.mod file found in the tree.
|
||||||
|
type Module struct {
|
||||||
|
Path string
|
||||||
|
File string
|
||||||
|
GoVersion string
|
||||||
|
Direct []Dependency
|
||||||
|
Indirect int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposeService is a service declared in a docker-compose file.
|
||||||
|
type ComposeService struct {
|
||||||
|
Name string
|
||||||
|
Image string
|
||||||
|
Ports string
|
||||||
|
File string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DockerInfo summarises the project's Dockerfile.
|
||||||
|
type DockerInfo struct {
|
||||||
|
File string
|
||||||
|
Stages []string
|
||||||
|
Bases []string
|
||||||
|
Exposed []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocFile is an existing Markdown document that ships with the project.
|
||||||
|
type DocFile struct {
|
||||||
|
Path string
|
||||||
|
Title string
|
||||||
|
Bytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dir is a first-level source directory with a purpose inferred from its name.
|
||||||
|
type Dir struct {
|
||||||
|
Name string
|
||||||
|
Files int
|
||||||
|
Lines int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language is a per-extension line count.
|
||||||
|
type Language struct {
|
||||||
|
Name string
|
||||||
|
Files int
|
||||||
|
Lines int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats are the headline counters shown in the generated page.
|
||||||
|
type Stats struct {
|
||||||
|
GoFiles int
|
||||||
|
TestFiles int
|
||||||
|
TestFuncs int
|
||||||
|
Components int
|
||||||
|
TotalLines int
|
||||||
|
OldestCommit string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project is the full analysis result for one repository.
|
||||||
|
type Project struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
Summary string
|
||||||
|
Readme string
|
||||||
|
|
||||||
|
Modules []Module
|
||||||
|
Routes []Route
|
||||||
|
Flags []Flag
|
||||||
|
EnvVars []EnvVar
|
||||||
|
MakeTargets []string
|
||||||
|
Compose []ComposeService
|
||||||
|
Docker *DockerInfo
|
||||||
|
HelmCharts []string
|
||||||
|
Docs []DocFile
|
||||||
|
Dirs []Dir
|
||||||
|
Languages []Language
|
||||||
|
Ports []string
|
||||||
|
Stats Stats
|
||||||
|
|
||||||
|
// Signature changes whenever any analysed input changes. Watch mode uses it
|
||||||
|
// to decide whether a regeneration is needed.
|
||||||
|
Signature string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze walks root and extracts everything Render needs.
|
||||||
|
//
|
||||||
|
// A missing or partial project is not an error: a repository without a go.mod
|
||||||
|
// or without a README still yields a usable page describing whatever was found.
|
||||||
|
func Analyze(name, root string) (*Project, error) {
|
||||||
|
info, err := os.Stat(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("docgen: stat %s: %w", root, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("docgen: %s is not a directory", root)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Project{Name: name, Path: root}
|
||||||
|
|
||||||
|
files, err := collectFiles(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Signature = signature(files)
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
rel := file.rel
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(rel, ".go"):
|
||||||
|
p.Stats.GoFiles++
|
||||||
|
p.Stats.TotalLines += file.lines
|
||||||
|
if strings.HasSuffix(rel, "_test.go") {
|
||||||
|
p.Stats.TestFiles++
|
||||||
|
p.Stats.TestFuncs += countTestFuncs(file.text())
|
||||||
|
}
|
||||||
|
if isComponentPath(rel) {
|
||||||
|
p.Stats.Components++
|
||||||
|
}
|
||||||
|
analyzeGoFile(p, file)
|
||||||
|
|
||||||
|
case filepath.Base(rel) == "go.mod":
|
||||||
|
p.Modules = append(p.Modules, parseGoMod(rel, file.text()))
|
||||||
|
|
||||||
|
case isMarkdown(rel):
|
||||||
|
p.Docs = append(p.Docs, DocFile{
|
||||||
|
Path: rel,
|
||||||
|
Title: markdownTitle(file.text()),
|
||||||
|
Bytes: int(file.size),
|
||||||
|
})
|
||||||
|
|
||||||
|
case filepath.Base(rel) == "Makefile" || filepath.Base(rel) == "makefile":
|
||||||
|
p.MakeTargets = append(p.MakeTargets, makeTargets(file.text())...)
|
||||||
|
|
||||||
|
case isDockerfile(rel):
|
||||||
|
if p.Docker == nil {
|
||||||
|
p.Docker = parseDockerfile(rel, file.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
case isComposeFile(rel):
|
||||||
|
p.Compose = append(p.Compose, parseCompose(rel, file.text())...)
|
||||||
|
|
||||||
|
case strings.HasPrefix(filepath.Base(rel), ".env"):
|
||||||
|
p.EnvVars = append(p.EnvVars, envFromExample(file.text())...)
|
||||||
|
|
||||||
|
case isHelmFile(rel):
|
||||||
|
p.HelmCharts = append(p.HelmCharts, chartRoot(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Languages = addLanguage(p.Languages, file)
|
||||||
|
if portBearingFiles(rel) {
|
||||||
|
p.Ports = append(p.Ports, portsIn(file.text())...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(p)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Normalisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func finish(p *Project) {
|
||||||
|
p.Readme = findReadme(p)
|
||||||
|
p.Summary = summarize(p)
|
||||||
|
|
||||||
|
p.Modules = dedupeModules(p.Modules)
|
||||||
|
p.Routes = dedupeRoutes(p.Routes)
|
||||||
|
p.Flags = dedupeFlags(p.Flags)
|
||||||
|
p.EnvVars = dedupeEnv(p.EnvVars)
|
||||||
|
p.MakeTargets = dedupeStrings(p.MakeTargets)
|
||||||
|
p.HelmCharts = dedupeStrings(p.HelmCharts)
|
||||||
|
p.Ports = dedupeStrings(p.Ports)
|
||||||
|
p.Dirs = topDirs(p)
|
||||||
|
p.Languages = rankLanguages(p.Languages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Walking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type scannedFile struct {
|
||||||
|
path string
|
||||||
|
rel string
|
||||||
|
size int64
|
||||||
|
lines int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f scannedFile) text() string {
|
||||||
|
data, err := readCapped(f.path, maxReadBytes)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignoredDirs never contain first-party source worth documenting.
|
||||||
|
var ignoredDirs = map[string]struct{}{
|
||||||
|
"node_modules": {}, ".git": {}, ".idea": {}, ".vscode": {}, "dist": {},
|
||||||
|
"build": {}, "bin": {}, "vendor": {}, "coverage": {}, "tmp": {},
|
||||||
|
".cache": {}, ".next": {}, "target": {}, "__pycache__": {}, ".venv": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectFiles(root string) ([]scannedFile, error) {
|
||||||
|
var files []scannedFile
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
// An unreadable subtree must not abort the whole analysis.
|
||||||
|
if entry != nil && entry.IsDir() {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(files) >= maxWalkFiles {
|
||||||
|
return fs.SkipAll
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() {
|
||||||
|
if _, skip := ignoredDirs[name]; skip && path != root {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !entry.Type().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isNoise(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
rel = name
|
||||||
|
}
|
||||||
|
files = append(files, scannedFile{
|
||||||
|
path: path,
|
||||||
|
rel: filepath.ToSlash(rel),
|
||||||
|
size: info.Size(),
|
||||||
|
lines: countLinesFast(path),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("docgen: walk %s: %w", root, err)
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNoise filters artefacts that would pollute the analysis.
|
||||||
|
func isNoise(name string) bool {
|
||||||
|
switch filepath.Ext(name) {
|
||||||
|
case ".log", ".lock", ".sum", ".pem", ".crt", ".key", ".png", ".jpg", ".jpeg",
|
||||||
|
".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".zip", ".tar",
|
||||||
|
".gz", ".mp4", ".sql.gz", ".db", ".sqlite", ".pyc":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Certificates and keys are frequently committed without extensions.
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(name, ".crt"), strings.HasSuffix(name, ".key"):
|
||||||
|
return true
|
||||||
|
case strings.HasSuffix(name, "-chain.crt"), strings.HasSuffix(name, "-root-ca.crt"):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMarkdown(rel string) bool {
|
||||||
|
return strings.EqualFold(filepath.Ext(rel), ".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDockerfile(rel string) bool {
|
||||||
|
base := filepath.Base(rel)
|
||||||
|
return base == "Dockerfile" || strings.HasPrefix(base, "Dockerfile.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isComposeFile(rel string) bool {
|
||||||
|
base := strings.ToLower(filepath.Base(rel))
|
||||||
|
if !strings.HasPrefix(base, "docker-compose") && base != "compose.yaml" && base != "compose.yml" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(base, ".yml") || strings.HasSuffix(base, ".yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHelmChart(rel string) bool {
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "helm" || part == "charts" {
|
||||||
|
// A chart is a directory that owns a values.yaml or Chart.yaml.
|
||||||
|
if i+1 < len(parts) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// chartRoot collapses a chart file path to the directory that owns the chart,
|
||||||
|
// so the page lists one entry per chart rather than one per template.
|
||||||
|
func chartRoot(rel string) string {
|
||||||
|
dir := path.Dir(rel)
|
||||||
|
if dir == "." {
|
||||||
|
return "./ (repository root)"
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func isComponentPath(rel string) bool {
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch parts[len(parts)-2] {
|
||||||
|
case "internal", "pkg", "cmd", "api", "services", "web", "ui", "frontend", "backend":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCapped(path string, limit int64) ([]byte, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return io.ReadAll(io.LimitReader(file, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
func countLinesFast(path string) int {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Skip .md / .json / .lock style files where line counts are meaningless.
|
||||||
|
switch strings.ToLower(filepath.Ext(path)) {
|
||||||
|
case ".md", ".json", ".yaml", ".yml", ".txt", ".lock", ".css", ".html":
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 32<<10)
|
||||||
|
lines := 0
|
||||||
|
for {
|
||||||
|
n, err := file.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
for _, b := range buf[:n] {
|
||||||
|
if b == '\n' {
|
||||||
|
lines++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// signature hashes the identity of every analysed file. It deliberately uses
|
||||||
|
// size and mtime rather than content so watch mode can poll large trees cheaply.
|
||||||
|
func signature(files []scannedFile) string {
|
||||||
|
hash := sha256.New()
|
||||||
|
for _, file := range files {
|
||||||
|
info, err := os.Stat(file.path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(hash, "%s|%d|%d\n", file.rel, file.size, info.ModTime().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))[:32]
|
||||||
|
}
|
||||||
|
|
||||||
|
func countTestFuncs(source string) int {
|
||||||
|
return strings.Count(source, "func Test") + strings.Count(source, "func Fuzz") +
|
||||||
|
strings.Count(source, "func Benchmark")
|
||||||
|
}
|
||||||
|
|
||||||
|
// noiseExtensions are file types that carry no language signal: build
|
||||||
|
// metadata, ignore lists and editor configuration all have extensions that would
|
||||||
|
// otherwise show up as their own "language".
|
||||||
|
var noiseExtensions = map[string]struct{}{
|
||||||
|
"mod": {}, "sum": {}, "lock": {}, "gitignore": {}, "dockerignore": {},
|
||||||
|
"helmignore": {}, "npmignore": {}, "eslintignore": {}, "gitattributes": {},
|
||||||
|
"env": {},
|
||||||
|
"editorconfig": {}, "prettierignore": {}, "example": {}, "template": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLanguage(langs []Language, file scannedFile) []Language {
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.path), "."))
|
||||||
|
if ext == "" {
|
||||||
|
return langs
|
||||||
|
}
|
||||||
|
if _, skip := noiseExtensions[ext]; skip {
|
||||||
|
return langs
|
||||||
|
}
|
||||||
|
name := languageName(ext)
|
||||||
|
lines := file.lines
|
||||||
|
if lines == 0 {
|
||||||
|
lines = countAllLinesFast(file.path)
|
||||||
|
}
|
||||||
|
for i := range langs {
|
||||||
|
if langs[i].Name == name {
|
||||||
|
langs[i].Files++
|
||||||
|
langs[i].Lines += lines
|
||||||
|
return langs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(langs, Language{Name: name, Files: 1, Lines: lines})
|
||||||
|
}
|
||||||
|
|
||||||
|
func countAllLinesFast(path string) int {
|
||||||
|
data, err := readCapped(path, maxReadBytes)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
lines := strings.Count(string(data), "\n")
|
||||||
|
if len(data) > 0 && !strings.HasSuffix(string(data), "\n") {
|
||||||
|
lines++
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func languageName(ext string) string {
|
||||||
|
switch ext {
|
||||||
|
case "go":
|
||||||
|
return "Go"
|
||||||
|
case "js", "mjs", "cjs":
|
||||||
|
return "JavaScript"
|
||||||
|
case "jsx":
|
||||||
|
return "JSX"
|
||||||
|
case "ts", "tsx":
|
||||||
|
return "TypeScript"
|
||||||
|
case "css":
|
||||||
|
return "CSS"
|
||||||
|
case "html", "htm":
|
||||||
|
return "HTML"
|
||||||
|
case "sh", "bash":
|
||||||
|
return "Shell"
|
||||||
|
case "sql":
|
||||||
|
return "SQL"
|
||||||
|
case "yml", "yaml":
|
||||||
|
return "YAML"
|
||||||
|
case "json":
|
||||||
|
return "JSON"
|
||||||
|
case "md":
|
||||||
|
return "Markdown"
|
||||||
|
case "tf":
|
||||||
|
return "Terraform"
|
||||||
|
case "proto":
|
||||||
|
return "Protobuf"
|
||||||
|
case "tpl", "gotmpl":
|
||||||
|
return "Templates"
|
||||||
|
case "py":
|
||||||
|
return "Python"
|
||||||
|
default:
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rankLanguages(langs []Language) []Language {
|
||||||
|
sort.Slice(langs, func(i, j int) bool {
|
||||||
|
if langs[i].Lines == langs[j].Lines {
|
||||||
|
return langs[i].Name < langs[j].Name
|
||||||
|
}
|
||||||
|
return langs[i].Lines > langs[j].Lines
|
||||||
|
})
|
||||||
|
// A page does not benefit from a tail of one-file languages.
|
||||||
|
if len(langs) > maxLanguages {
|
||||||
|
langs = langs[:maxLanguages]
|
||||||
|
}
|
||||||
|
return langs
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxLanguages caps the language table on a generated page.
|
||||||
|
const maxLanguages = 10
|
||||||
|
|
||||||
|
func topDirs(p *Project) []Dir {
|
||||||
|
byName := map[string]*Dir{}
|
||||||
|
err := filepath.WalkDir(p.Path, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
if path != p.Path {
|
||||||
|
if _, skip := ignoredDirs[entry.Name()]; skip {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
// Dot directories hold tooling, not first-party source.
|
||||||
|
if strings.HasPrefix(entry.Name(), ".") {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(p.Path, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir := byName[parts[0]]
|
||||||
|
if dir == nil {
|
||||||
|
dir = &Dir{Name: parts[0]}
|
||||||
|
byName[parts[0]] = dir
|
||||||
|
}
|
||||||
|
dir.Files++
|
||||||
|
dir.Lines += countLinesFast(path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs := make([]Dir, 0, len(byName))
|
||||||
|
for _, dir := range byName {
|
||||||
|
dirs = append(dirs, *dir)
|
||||||
|
}
|
||||||
|
sort.Slice(dirs, func(i, j int) bool {
|
||||||
|
if dirs[i].Files == dirs[j].Files {
|
||||||
|
return dirs[i].Name < dirs[j].Name
|
||||||
|
}
|
||||||
|
return dirs[i].Files > dirs[j].Files
|
||||||
|
})
|
||||||
|
if len(dirs) > 14 {
|
||||||
|
dirs = dirs[:14]
|
||||||
|
}
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeStrings(in []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
for _, value := range in {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[value]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupeInOrder removes duplicates while preserving first-seen order, for lists
|
||||||
|
// whose order is meaningful (Docker build stages, published ports).
|
||||||
|
func dedupeInOrder(in []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
for _, value := range in {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[value]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeModules(mods []Module) []Module {
|
||||||
|
sort.Slice(mods, func(i, j int) bool { return mods[i].File < mods[j].File })
|
||||||
|
return mods
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupeRoutes collapses registrations that describe the same endpoint. The
|
||||||
|
// key is method plus path, because one endpoint is often registered in several
|
||||||
|
// places (a group helper plus the router); when several carry a handler name,
|
||||||
|
// the one with a handler wins over the one without.
|
||||||
|
func dedupeRoutes(routes []Route) []Route {
|
||||||
|
best := make(map[string]Route, len(routes))
|
||||||
|
for _, route := range routes {
|
||||||
|
if route.Path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := route.Method + " " + route.Path
|
||||||
|
existing, seen := best[key]
|
||||||
|
switch {
|
||||||
|
case !seen:
|
||||||
|
best[key] = route
|
||||||
|
case existing.Handler == "" && route.Handler != "":
|
||||||
|
best[key] = route
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Route, 0, len(best))
|
||||||
|
for _, route := range best {
|
||||||
|
out = append(out, route)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Path == out[j].Path {
|
||||||
|
return out[i].Method < out[j].Method
|
||||||
|
}
|
||||||
|
return out[i].Path < out[j].Path
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeFlags(flags []Flag) []Flag {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]Flag, 0, len(flags))
|
||||||
|
for _, flag := range flags {
|
||||||
|
if _, dup := seen[flag.Name]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[flag.Name] = struct{}{}
|
||||||
|
out = append(out, flag)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeEnv(vars []EnvVar) []EnvVar {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]EnvVar, 0, len(vars))
|
||||||
|
for _, v := range vars {
|
||||||
|
if _, dup := seen[v.Name]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v.Name] = struct{}{}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp is used inside generated markers so a reader can tell how fresh a
|
||||||
|
// machine-written region is.
|
||||||
|
func Timestamp() string { return time.Now().UTC().Format("2006-01-02 15:04 UTC") }
|
||||||
621
backend/docgen/docgen_test.go
Normal file
621
backend/docgen/docgen_test.go
Normal file
|
|
@ -0,0 +1,621 @@
|
||||||
|
package docgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixture writes a small but representative project tree: all three route
|
||||||
|
// registration styles, a module file, a Makefile, a Dockerfile, a compose file,
|
||||||
|
// an .env, a README and one unrelated Markdown document.
|
||||||
|
func fixture(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
files := map[string]string{
|
||||||
|
"README.md": "# Demo Service\n\nDemo is a service that answers questions about things. " +
|
||||||
|
"It runs behind Go Loose and exposes a JSON API.\n\n" +
|
||||||
|
"## Install\n\nNot part of the summary.\n",
|
||||||
|
|
||||||
|
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire (\n" +
|
||||||
|
"\tgithub.com/go-chi/chi/v5 v5.2.3\n\tgithub.com/spf13/cobra v1.9.1\n" +
|
||||||
|
"\tgolang.org/x/sync v0.14.0 // indirect\n)\n",
|
||||||
|
|
||||||
|
"Makefile": "build:\n\tgo build ./...\n\n.PHONY: build\n\ntest:\n\tgo test ./...\n",
|
||||||
|
|
||||||
|
"Dockerfile": "FROM golang:1.27-alpine AS builder\nRUN go build ./...\n\n" +
|
||||||
|
"FROM alpine:3.20\nCOPY --from=builder /app/demo /demo\nEXPOSE 8080\n",
|
||||||
|
|
||||||
|
"docker-compose.yml": "services:\n api:\n image: demo:latest\n ports:\n" +
|
||||||
|
" - \"8080:8080\"\n db:\n image: postgres:17\n environment:\n" +
|
||||||
|
" POSTGRES_PASSWORD: secret\n",
|
||||||
|
|
||||||
|
".env.example": "# comment\nDEMO_HTTP_PORT=8080\nDEMO_API_KEY=\n",
|
||||||
|
|
||||||
|
"internal/server/routes.go": "package server\n\n" +
|
||||||
|
"import (\n\t\"net/http\"\n\t\"os\"\n)\n\n" +
|
||||||
|
"func Routes(mux *http.ServeMux, r chi.Router) {\n" +
|
||||||
|
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
|
||||||
|
"\tmux.HandleFunc(\"/plain\", plainHandler)\n" +
|
||||||
|
"\tregister(r, http.MethodPost, \"/api/items\", createItem)\n" +
|
||||||
|
"\tr.Get(\"/api/items/{id}\", getItem)\n" +
|
||||||
|
"\t_ = os.Getenv(\"DEMO_RUNTIME_SECRET\")\n}\n",
|
||||||
|
|
||||||
|
"internal/server/routes_test.go": "package server\n\n" +
|
||||||
|
"func TestRoutes(t *testing.T) {\n" +
|
||||||
|
"\t_ = httptest.NewRequest(http.MethodGet, \"/api/jobs\", nil)\n}\n",
|
||||||
|
|
||||||
|
"cmd/demo/main.go": "package main\n\nimport \"flag\"\n\nfunc main() {\n" +
|
||||||
|
"\tport := flag.String(\"port\", \"8080\", \"listen port\")\n" +
|
||||||
|
"\tverbose := flag.Bool(\"verbose\", false, \"chatty output\")\n" +
|
||||||
|
"\t_ = port\n\t_ = verbose\n}\n",
|
||||||
|
|
||||||
|
"docs/OPERATIONS.md": "# Operations\n\nHow to run the thing.\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A directory that must never be walked.
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "node_modules", "left-pad"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir node_modules: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "node_modules", "index.js"), []byte("x\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write node_modules file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeFixture(t *testing.T) *Project {
|
||||||
|
t.Helper()
|
||||||
|
p, err := Analyze("demo", fixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Analyze: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeExtractsModuleMetadata(t *testing.T) {
|
||||||
|
p := analyzeFixture(t)
|
||||||
|
|
||||||
|
if len(p.Modules) != 1 {
|
||||||
|
t.Fatalf("want 1 module, got %d: %+v", len(p.Modules), p.Modules)
|
||||||
|
}
|
||||||
|
module := p.Modules[0]
|
||||||
|
if module.Path != "github.com/danyel/demo" {
|
||||||
|
t.Errorf("module path = %q", module.Path)
|
||||||
|
}
|
||||||
|
if module.GoVersion != "1.27.1" {
|
||||||
|
t.Errorf("go version = %q", module.GoVersion)
|
||||||
|
}
|
||||||
|
if len(module.Direct) != 2 {
|
||||||
|
t.Errorf("want 2 direct deps, got %d: %+v", len(module.Direct), module.Direct)
|
||||||
|
}
|
||||||
|
if module.Direct[0].Path != "github.com/go-chi/chi/v5" || module.Direct[0].Version != "v5.2.3" {
|
||||||
|
t.Errorf("unexpected first dep: %+v", module.Direct[0])
|
||||||
|
}
|
||||||
|
if module.Indirect != 1 {
|
||||||
|
t.Errorf("want 1 indirect dep, got %d", module.Indirect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeExtractsAllRouteStyles(t *testing.T) {
|
||||||
|
p := analyzeFixture(t)
|
||||||
|
|
||||||
|
want := map[string]string{
|
||||||
|
"GET /healthz": "health",
|
||||||
|
"/plain": "plainHandler",
|
||||||
|
"POST /api/items": "createItem",
|
||||||
|
"GET /api/items/{id}": "getItem",
|
||||||
|
}
|
||||||
|
for key, handler := range want {
|
||||||
|
var found *Route
|
||||||
|
for i := range p.Routes {
|
||||||
|
if p.Routes[i].String() == key {
|
||||||
|
found = &p.Routes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Errorf("route %q not found; got %v", key, routeStrings(p.Routes))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if found.Handler != handler {
|
||||||
|
t.Errorf("route %q handler = %q, want %q", key, found.Handler, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.Routes) != len(want) {
|
||||||
|
t.Errorf("want %d routes, got %d: %v", len(want), len(p.Routes), routeStrings(p.Routes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeIgnoresRoutesInTestFiles(t *testing.T) {
|
||||||
|
p := analyzeFixture(t)
|
||||||
|
|
||||||
|
for _, route := range p.Routes {
|
||||||
|
if route.Path == "/api/jobs" {
|
||||||
|
t.Fatalf("route from a _test.go file leaked into the analysis: %v", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeExtractsFlagsAndEnvironment(t *testing.T) {
|
||||||
|
p := analyzeFixture(t)
|
||||||
|
|
||||||
|
flags := map[string]Flag{}
|
||||||
|
for _, flag := range p.Flags {
|
||||||
|
flags[flag.Name] = flag
|
||||||
|
}
|
||||||
|
if flags["port"].Default != "8080" || flags["port"].Usage != "listen port" {
|
||||||
|
t.Errorf("port flag = %+v", flags["port"])
|
||||||
|
}
|
||||||
|
if flags["verbose"].Type != "bool" {
|
||||||
|
t.Errorf("verbose flag = %+v", flags["verbose"])
|
||||||
|
}
|
||||||
|
|
||||||
|
env := map[string]EnvVar{}
|
||||||
|
for _, v := range p.EnvVars {
|
||||||
|
env[v.Name] = v
|
||||||
|
}
|
||||||
|
if env["DEMO_RUNTIME_SECRET"].FromEnv {
|
||||||
|
t.Error("os.Getenv should not be reported as coming from a .env file")
|
||||||
|
}
|
||||||
|
if !env["DEMO_HTTP_PORT"].FromEnv || env["DEMO_HTTP_PORT"].Example != "8080" {
|
||||||
|
t.Errorf("DEMO_HTTP_PORT = %+v", env["DEMO_HTTP_PORT"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeExtractsOperationalFacts(t *testing.T) {
|
||||||
|
p := analyzeFixture(t)
|
||||||
|
|
||||||
|
if got := p.MakeTargets; len(got) != 2 || got[0] != "build" || got[1] != "test" {
|
||||||
|
t.Errorf("make targets = %v", got)
|
||||||
|
}
|
||||||
|
if p.Docker == nil {
|
||||||
|
t.Fatal("no Dockerfile analysed")
|
||||||
|
}
|
||||||
|
if len(p.Docker.Bases) != 2 || p.Docker.Bases[0] != "golang:1.27-alpine" {
|
||||||
|
t.Errorf("docker bases = %v", p.Docker.Bases)
|
||||||
|
}
|
||||||
|
if len(p.Docker.Exposed) != 1 || p.Docker.Exposed[0] != "8080" {
|
||||||
|
t.Errorf("docker exposed = %v", p.Docker.Exposed)
|
||||||
|
}
|
||||||
|
if len(p.Compose) != 2 {
|
||||||
|
t.Fatalf("compose services = %+v", p.Compose)
|
||||||
|
}
|
||||||
|
if p.Compose[0].Name != "api" || p.Compose[0].Image != "demo:latest" {
|
||||||
|
t.Errorf("compose[0] = %+v", p.Compose[0])
|
||||||
|
}
|
||||||
|
if len(p.Ports) == 0 {
|
||||||
|
t.Error("expected the compose file port to be picked up")
|
||||||
|
}
|
||||||
|
if p.Readme != "README.md" {
|
||||||
|
t.Errorf("readme = %q", p.Readme)
|
||||||
|
}
|
||||||
|
if len(p.Docs) != 2 {
|
||||||
|
t.Errorf("docs = %+v", p.Docs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeIgnoresVendoredTrees(t *testing.T) {
|
||||||
|
p := analyzeFixture(t)
|
||||||
|
|
||||||
|
for _, lang := range p.Languages {
|
||||||
|
if lang.Name == "JavaScript" {
|
||||||
|
t.Errorf("node_modules leaked into the language table: %+v", p.Languages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, dir := range p.Dirs {
|
||||||
|
if dir.Name == "node_modules" {
|
||||||
|
t.Errorf("node_modules leaked into the directory map: %+v", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummaryComesFromReadmeProse(t *testing.T) {
|
||||||
|
p := analyzeFixture(t)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(p.Summary, "Demo is a service") {
|
||||||
|
t.Errorf("summary = %q", p.Summary)
|
||||||
|
}
|
||||||
|
if strings.Contains(p.Summary, "Install") {
|
||||||
|
t.Errorf("summary leaked a later section: %q", p.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeWithoutReadmeFallsBackToFacts(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := Analyze("bare", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Analyze: %v", err)
|
||||||
|
}
|
||||||
|
if p.Summary == "" {
|
||||||
|
t.Error("a project with no README still needs a summary")
|
||||||
|
}
|
||||||
|
if p.Stats.GoFiles != 1 {
|
||||||
|
t.Errorf("go files = %d", p.Stats.GoFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignatureChangesOnlyWithTheTree(t *testing.T) {
|
||||||
|
root := fixture(t)
|
||||||
|
|
||||||
|
first, err := Analyze("demo", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := Analyze("demo", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.Signature != again.Signature {
|
||||||
|
t.Errorf("signature is not stable: %q vs %q", first.Signature, again.Signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
changed, err := Analyze("demo", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if changed.Signature == first.Signature {
|
||||||
|
t.Error("signature did not change after a file was added")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRenderProducesAudienceSections(t *testing.T) {
|
||||||
|
page, err := Render(analyzeFixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"# demo",
|
||||||
|
"## For Users",
|
||||||
|
"## For Operators",
|
||||||
|
"## For Developers",
|
||||||
|
"### HTTP API",
|
||||||
|
"| Method | Path | Handler |",
|
||||||
|
"| `GET` | `/healthz` | `health` |",
|
||||||
|
"### Direct dependencies",
|
||||||
|
"`github.com/go-chi/chi/v5` v5.2.3",
|
||||||
|
"### Network surface",
|
||||||
|
"`make build`",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(page, want) {
|
||||||
|
t.Errorf("rendered page is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderIsWrappedInMarkers(t *testing.T) {
|
||||||
|
page, err := Render(analyzeFixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(page, beginPrefix) {
|
||||||
|
t.Errorf("page does not start with the opening marker:\n%s", firstLine(page))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(page, EndMarker) {
|
||||||
|
t.Errorf("page does not end with the closing marker:\n%s", lastLine(page))
|
||||||
|
}
|
||||||
|
if !FindBlock(page).Found {
|
||||||
|
t.Error("FindBlock cannot locate the markers on a freshly rendered page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderKeepsMarkdownSpacingSane(t *testing.T) {
|
||||||
|
page, err := Render(analyzeFixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim the marker lines: they are not part of the Markdown body.
|
||||||
|
body := page
|
||||||
|
if block := FindBlock(page); block.Found {
|
||||||
|
body = page[block.Body : block.End-len(EndMarker)]
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(body, "\n")
|
||||||
|
for i := 0; i < len(lines)-1; i++ {
|
||||||
|
if lines[i] == "" && lines[i+1] == "" {
|
||||||
|
t.Errorf("blank line %d: two blank lines in a row", i+1)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(lines[i], "#") && lines[i+1] != "" {
|
||||||
|
t.Errorf("heading on line %d is glued to %q", i+1, lines[i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(lines[0]), "<!--") {
|
||||||
|
t.Errorf("body starts with a comment: %q", lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderIsDeterministic(t *testing.T) {
|
||||||
|
root := fixture(t)
|
||||||
|
first, err := Analyze("demo", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := Analyze("demo", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := Render(first)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := Render(second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The only permitted difference is the generation timestamp.
|
||||||
|
if stampPattern.ReplaceAllString(a, "") != stampPattern.ReplaceAllString(b, "") {
|
||||||
|
t.Error("Render is not deterministic for an unchanged tree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Merging
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestMergeCreatesWhenNoPageExists(t *testing.T) {
|
||||||
|
merged, status, err := Merge("", "generated body\n", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != StatusCreated {
|
||||||
|
t.Errorf("status = %q", status)
|
||||||
|
}
|
||||||
|
if merged != "generated body\n" {
|
||||||
|
t.Errorf("merged = %q", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeSkipsUnmarkedPages(t *testing.T) {
|
||||||
|
const handWritten = "# demo\n\nWritten by a person.\n"
|
||||||
|
|
||||||
|
merged, status, err := Merge(handWritten, "generated\n", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != StatusSkipped {
|
||||||
|
t.Errorf("status = %q", status)
|
||||||
|
}
|
||||||
|
if merged != handWritten {
|
||||||
|
t.Errorf("a hand-written page was modified: %q", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeForcesOverUnmarkedPages(t *testing.T) {
|
||||||
|
merged, status, err := Merge("# demo\n\nWritten by a person.\n", "generated\n", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != StatusUpdated {
|
||||||
|
t.Errorf("status = %q", status)
|
||||||
|
}
|
||||||
|
if merged != "generated\n" {
|
||||||
|
t.Errorf("merged = %q", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergePreservesProseOutsideMarkers(t *testing.T) {
|
||||||
|
page, err := Render(analyzeFixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
withFooter := page + "\n## House rules\n\nNever edit the generated part by hand.\n"
|
||||||
|
|
||||||
|
// A second render of the same tree with different content, as if the
|
||||||
|
// project's README had been rewritten.
|
||||||
|
block := FindBlock(withFooter)
|
||||||
|
if !block.Found {
|
||||||
|
t.Fatal("markers missing from the page under test")
|
||||||
|
}
|
||||||
|
updated := strings.Replace(page, "Demo is a service", "Demo is a service, renamed", 1)
|
||||||
|
if updated == page {
|
||||||
|
t.Fatal("test setup failed to change the generated content")
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, status, err := Merge(withFooter, updated, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != StatusUpdated {
|
||||||
|
t.Errorf("status = %q", status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(merged, "Never edit the generated part by hand.") {
|
||||||
|
t.Error("hand-written footer was dropped")
|
||||||
|
}
|
||||||
|
if !strings.Contains(merged, "Demo is a service, renamed") {
|
||||||
|
t.Error("generated region was not replaced")
|
||||||
|
}
|
||||||
|
if got := strings.Count(merged, EndMarker); got != 1 {
|
||||||
|
t.Errorf("want exactly 1 closing marker, got %d", got)
|
||||||
|
}
|
||||||
|
if got := strings.Count(merged, beginPrefix); got != 1 {
|
||||||
|
t.Errorf("want exactly 1 opening marker, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeReportsUnchangedAndKeepsTheFileStable(t *testing.T) {
|
||||||
|
page, err := Render(analyzeFixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Storage normalises the trailing newline, so the on-disk page has one.
|
||||||
|
onDisk := page + "\n"
|
||||||
|
|
||||||
|
merged, status, err := Merge(onDisk, page, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != StatusUnchanged {
|
||||||
|
t.Errorf("status = %q, want unchanged", status)
|
||||||
|
}
|
||||||
|
if merged != onDisk {
|
||||||
|
t.Error("an unchanged sync must return the file untouched, timestamp included")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindBlockIgnoresMalformedMarkers(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"no markers at all": "# demo\n\nBody.\n",
|
||||||
|
"only an opening marker": beginPrefix + " source=demo -->\n\nBody.\n",
|
||||||
|
"only a closing marker": "Body.\n" + EndMarker + "\n",
|
||||||
|
"marker not at line start": "text " + beginPrefix + " -->\nBody.\n" + EndMarker + "\n",
|
||||||
|
"markers out of order": EndMarker + "\n" + beginPrefix + " -->\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, content := range tests {
|
||||||
|
if block := FindBlock(content); block.Found {
|
||||||
|
t.Errorf("%s: expected no block, got %+v", name, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindBlockReportsBoundaries(t *testing.T) {
|
||||||
|
page, err := Render(analyzeFixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block := FindBlock(page)
|
||||||
|
if !block.Found {
|
||||||
|
t.Fatal("block not found")
|
||||||
|
}
|
||||||
|
if block.Marker != 0 {
|
||||||
|
t.Errorf("marker offset = %d, want 0", block.Marker)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(page[block.Body:], "# demo") {
|
||||||
|
t.Errorf("body does not start after the opening marker line: %q", page[block.Body:block.Body+8])
|
||||||
|
}
|
||||||
|
if got := page[block.End:]; got != "" {
|
||||||
|
t.Errorf("bytes after the block = %q, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapNormalisesTrailingNewlines(t *testing.T) {
|
||||||
|
page := Wrap("demo", "sig", "body\n\n\n")
|
||||||
|
if strings.HasSuffix(page, "\n") {
|
||||||
|
t.Error("Wrap must not end with a newline")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(page, "\n"+EndMarker) {
|
||||||
|
t.Errorf("the closing marker must start its own line: %q", lastLine(page))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func routeStrings(routes []Route) []string {
|
||||||
|
out := make([]string, 0, len(routes))
|
||||||
|
for _, route := range routes {
|
||||||
|
out = append(out, route.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastLine(s string) string {
|
||||||
|
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[i+1:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergeIgnoresAMinuteOldTimestamp is the regression test for a bug that
|
||||||
|
// only showed up once a minute: two renders of an unchanged project carry
|
||||||
|
// different "generated=" stamps, and a pattern that stopped at the first space
|
||||||
|
// left "15:04 UTC" in place, so every sync in a new minute rewrote all four
|
||||||
|
// pages and reported "updated" for content that had not changed at all.
|
||||||
|
func TestMergeIgnoresAMinuteOldTimestamp(t *testing.T) {
|
||||||
|
page, err := Render(analyzeFixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate a page imported an hour ago, down to the stamp.
|
||||||
|
stale := strings.Replace(page, "generated="+Timestamp(), "generated=2001-01-01 00:00 UTC", 1)
|
||||||
|
if stale == page {
|
||||||
|
t.Fatal("could not age the timestamp; the marker format changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, status, err := Merge(stale, page, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != StatusUnchanged {
|
||||||
|
t.Errorf("status = %q, want unchanged for an hour-old page with identical content", status)
|
||||||
|
}
|
||||||
|
if merged != stale {
|
||||||
|
t.Error("an unchanged sync must return the file untouched, timestamp included")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuine content change must still be reported, even with an old stamp.
|
||||||
|
edited := strings.Replace(stale, "# demo", "# demo, revised", 1)
|
||||||
|
if edited == stale {
|
||||||
|
t.Fatal("could not edit the title")
|
||||||
|
}
|
||||||
|
merged, status, err = Merge(stale, edited, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != StatusUpdated {
|
||||||
|
t.Errorf("status = %q, want updated for a real content change", status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(merged, "# demo, revised") {
|
||||||
|
t.Error("the edit did not reach the merged page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWrapStampIsFullyStripped guards the pattern itself: every byte of the
|
||||||
|
// timestamp has to be removed, not just the date.
|
||||||
|
func TestWrapStampIsFullyStripped(t *testing.T) {
|
||||||
|
page, err := Render(analyzeFixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stripped := stampPattern.ReplaceAllString(page, stampReplacement)
|
||||||
|
if strings.Contains(stripped, " UTC") {
|
||||||
|
t.Errorf("the timestamp was not fully stripped: %q", firstLineOf(stripped))
|
||||||
|
}
|
||||||
|
if strings.Count(stripped, "-->") != strings.Count(page, "-->") {
|
||||||
|
t.Error("stripping the stamp swallowed more than the timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLineOf(s string) string {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
560
backend/docgen/extract.go
Normal file
560
backend/docgen/extract.go
Normal file
|
|
@ -0,0 +1,560 @@
|
||||||
|
package docgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Go source extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
// net/http style registrations, including the Go 1.22 method-prefixed form
|
||||||
|
// mux.HandleFunc("GET /health", h). The literal is captured whole and
|
||||||
|
// validated in parseRoute, which is where the method prefix is split off.
|
||||||
|
routeHandleCall = regexp.MustCompile(`\b(?:Handle|HandleFunc)\(\s*"([^"]+)"\s*(?:,\s*([A-Za-z0-9_.]+))?`)
|
||||||
|
|
||||||
|
// Route tables that pass the method as an http.MethodXxx constant, as
|
||||||
|
// go-guess does: register(router, spec, false, http.MethodGet, "/api/health", h)
|
||||||
|
routeMethodConst = regexp.MustCompile(`\bhttp\.Method([A-Za-z]+)\s*,\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`)
|
||||||
|
|
||||||
|
// Router-value registrations: r.Get("/api/x", h.X)
|
||||||
|
routeRouterCall = regexp.MustCompile(`\b\w+\.(Get|Post|Put|Patch|Delete|Head|Options|Trace)\(\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`)
|
||||||
|
|
||||||
|
// "GET /api/v1/x" as a literal argument.
|
||||||
|
methodPrefix = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S.*)$`)
|
||||||
|
|
||||||
|
// flag.String("name", default, "usage")
|
||||||
|
flagCall = regexp.MustCompile(`flag\.(\w+)\(\s*"([^"]+)"\s*(?:,\s*([^,)]+))?\s*(?:,\s*"([^"]*)")?`)
|
||||||
|
|
||||||
|
// os.Getenv("X") / os.LookupEnv("X")
|
||||||
|
envCall = regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(\s*"([A-Z][A-Z0-9_]*)"`)
|
||||||
|
|
||||||
|
// Port literals anywhere in the project.
|
||||||
|
portLiteral = regexp.MustCompile(`(?:\bPORT\b|\bport\b|EXPOSE)[^0-9]{0,20}([0-9]{2,5})\b`)
|
||||||
|
|
||||||
|
testFuncDecl = regexp.MustCompile(`(?m)^func\s+(Test|Benchmark|Fuzz)\w*\(`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// analyzeGoFile pulls routes, flags and environment variables out of one source
|
||||||
|
// file. Each extractor is independent: a syntax oddity in one never affects the
|
||||||
|
// others because these are textual scans, not a type-checked AST walk.
|
||||||
|
//
|
||||||
|
// Test files are excluded. They build requests with httptest.NewRequest, which
|
||||||
|
// looks exactly like a route registration, and a route table is never declared
|
||||||
|
// in a _test.go file.
|
||||||
|
func analyzeGoFile(p *Project, file scannedFile) {
|
||||||
|
if strings.HasSuffix(file.rel, "_test.go") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
source := file.text()
|
||||||
|
if source == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range routeHandleCall.FindAllStringSubmatch(source, -1) {
|
||||||
|
if route := parseRoute(match[1], match[2]); route.Path != "" {
|
||||||
|
p.Routes = append(p.Routes, route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, match := range routeMethodConst.FindAllStringSubmatch(source, -1) {
|
||||||
|
p.Routes = append(p.Routes, Route{
|
||||||
|
Method: strings.ToUpper(match[1]),
|
||||||
|
Path: match[2],
|
||||||
|
Handler: handlerName(match[3]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, match := range routeRouterCall.FindAllStringSubmatch(source, -1) {
|
||||||
|
p.Routes = append(p.Routes, Route{
|
||||||
|
Method: strings.ToUpper(match[1]),
|
||||||
|
Path: match[2],
|
||||||
|
Handler: handlerName(match[3]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range flagCall.FindAllStringSubmatch(source, -1) {
|
||||||
|
p.Flags = append(p.Flags, Flag{
|
||||||
|
Name: match[2],
|
||||||
|
Type: strings.ToLower(match[1]),
|
||||||
|
Default: unquote(strings.TrimSpace(match[3])),
|
||||||
|
Usage: strings.TrimSpace(match[4]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range envCall.FindAllStringSubmatch(source, -1) {
|
||||||
|
p.EnvVars = append(p.EnvVars, EnvVar{Name: match[1]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRoute splits an optional method prefix and normalises the path. A
|
||||||
|
// literal that is not a route — a query key, a header name, a MIME type — is
|
||||||
|
// rejected by requiring the path to look like one.
|
||||||
|
func parseRoute(raw, handler string) Route {
|
||||||
|
method, path := "", strings.TrimSpace(raw)
|
||||||
|
if match := methodPrefix.FindStringSubmatch(raw); match != nil {
|
||||||
|
method, path = match[1], strings.TrimSpace(match[2])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
return Route{}
|
||||||
|
}
|
||||||
|
return Route{Method: method, Path: path, Handler: handlerName(handler)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerNames that are never useful in a route table: they are literal
|
||||||
|
// arguments, receivers or framework values rather than handler functions.
|
||||||
|
var noiseHandlers = map[string]struct{}{
|
||||||
|
"nil": {}, "true": {}, "false": {}, "spec": {}, "router": {}, "mux": {},
|
||||||
|
"r": {}, "g": {}, "chi": {}, "group": {}, "protected": {}, "public": {},
|
||||||
|
"api": {}, "v1": {}, "sub": {}, "app": {}, "server": {}, "srv": {},
|
||||||
|
"ctx": {}, "c": {}, "w": {}, "req": {}, "request": {}, "http": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerName reduces a handler expression to a readable name, dropping the
|
||||||
|
// receiver and rejecting anything that is not plainly a function reference.
|
||||||
|
func handlerName(handler string) string {
|
||||||
|
handler = strings.TrimSpace(handler)
|
||||||
|
if handler == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(handler, "."); i >= 0 {
|
||||||
|
handler = handler[i+1:]
|
||||||
|
}
|
||||||
|
handler = strings.TrimSuffix(handler, ".func1")
|
||||||
|
if _, noise := noiseHandlers[handler]; noise {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if handler == "HandlerFunc" || strings.Contains(handler, "(") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// go.mod
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
modLine = regexp.MustCompile(`^module\s+(\S+)`)
|
||||||
|
goVersionLine = regexp.MustCompile(`^go\s+(\S+)`)
|
||||||
|
requireLine = regexp.MustCompile(`^\s*(\S+)\s+(v\S+)(.*)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseGoMod reads a go.mod outside any require block.
|
||||||
|
func parseGoMod(rel, source string) Module {
|
||||||
|
module := Module{File: rel}
|
||||||
|
inBlock := false
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(source, "\n") {
|
||||||
|
line := strings.TrimRight(raw, " \t")
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(trimmed, "module "):
|
||||||
|
if module.Path == "" {
|
||||||
|
module.Path = strings.TrimSpace(strings.TrimPrefix(trimmed, "module "))
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(trimmed, "go "):
|
||||||
|
if module.GoVersion == "" {
|
||||||
|
if match := goVersionLine.FindStringSubmatch(trimmed); match != nil {
|
||||||
|
module.GoVersion = match[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case trimmed == "require (":
|
||||||
|
inBlock = true
|
||||||
|
case inBlock && trimmed == ")":
|
||||||
|
inBlock = false
|
||||||
|
case inBlock && trimmed != "" && !strings.HasPrefix(trimmed, "//"):
|
||||||
|
match := requireLine.FindStringSubmatch(line)
|
||||||
|
if match != nil {
|
||||||
|
addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect"))
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(trimmed, "require ") && !inBlock:
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "require "))
|
||||||
|
if match := requireLine.FindStringSubmatch(" " + rest); match != nil {
|
||||||
|
addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return module
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDependency(module *Module, path, version string, indirect bool) {
|
||||||
|
if path == "" || strings.HasPrefix(path, "//") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if indirect {
|
||||||
|
module.Indirect++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
module.Direct = append(module.Direct, Dependency{Path: path, Version: version})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Makefile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var makeTargetLine = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9_.-]*):`)
|
||||||
|
|
||||||
|
// makeTargets extracts invocable targets, skipping .PHONY declarations and
|
||||||
|
// variable assignments.
|
||||||
|
func makeTargets(source string) []string {
|
||||||
|
var targets []string
|
||||||
|
phony := map[string]struct{}{}
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(source, "\n") {
|
||||||
|
if strings.HasPrefix(raw, "\t") || strings.HasPrefix(raw, " ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line := strings.TrimRight(raw, " \t")
|
||||||
|
if strings.HasPrefix(line, ".PHONY") {
|
||||||
|
for _, name := range strings.Fields(line) {
|
||||||
|
if name == ".PHONY:" || name == ".PHONY" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
phony[strings.TrimSuffix(name, ":")] = struct{}{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match := makeTargetLine.FindStringSubmatch(line)
|
||||||
|
if match == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := match[1]
|
||||||
|
if strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, isPhony := phony[name]; isPhony {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets = append(targets, name)
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Docker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
dockerFrom = regexp.MustCompile(`(?mi)^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?`)
|
||||||
|
dockerExp = regexp.MustCompile(`(?mi)^\s*EXPOSE\s+(\S+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseDockerfile(rel, source string) *DockerInfo {
|
||||||
|
info := &DockerInfo{File: rel}
|
||||||
|
for _, match := range dockerFrom.FindAllStringSubmatch(source, -1) {
|
||||||
|
info.Bases = append(info.Bases, match[1])
|
||||||
|
if match[2] != "" {
|
||||||
|
info.Stages = append(info.Stages, match[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, match := range dockerExp.FindAllStringSubmatch(source, -1) {
|
||||||
|
info.Exposed = append(info.Exposed, match[1])
|
||||||
|
}
|
||||||
|
// Order matters: the first FROM is the build stage and the last is the
|
||||||
|
// runtime image, so these lists are deduplicated but never sorted.
|
||||||
|
info.Bases = dedupeInOrder(info.Bases)
|
||||||
|
info.Stages = dedupeInOrder(info.Stages)
|
||||||
|
info.Exposed = dedupeInOrder(info.Exposed)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// docker-compose
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// parseCompose reads service names, images and published ports out of a compose
|
||||||
|
// file. It is a line scanner rather than a YAML parser: compose files in these
|
||||||
|
// projects are consistently indented two spaces per level, and avoiding a YAML
|
||||||
|
// dependency keeps the module dependency-free.
|
||||||
|
func parseCompose(rel, source string) []ComposeService {
|
||||||
|
var (
|
||||||
|
services []ComposeService
|
||||||
|
inBlock bool
|
||||||
|
current int = -1
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(source, "\n") {
|
||||||
|
line := strings.TrimRight(raw, " \t")
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
indent := len(line) - len(strings.TrimLeft(line, " "))
|
||||||
|
|
||||||
|
if indent == 0 {
|
||||||
|
inBlock = trimmed == "services:"
|
||||||
|
current = -1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inBlock {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if indent == 2 && strings.HasSuffix(trimmed, ":") {
|
||||||
|
services = append(services, ComposeService{Name: strings.TrimSuffix(trimmed, ":"), File: rel})
|
||||||
|
current = len(services) - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if current < 0 || indent != 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key, value, found := strings.Cut(trimmed, ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "image":
|
||||||
|
services[current].Image = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||||
|
case "ports":
|
||||||
|
if services[current].Ports == "" {
|
||||||
|
services[current].Ports = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||||
|
} else {
|
||||||
|
services[current].Ports += ", " + strings.Trim(strings.TrimSpace(value), `"'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the well-known internal networks and volume declarations that the
|
||||||
|
// indentation scanner otherwise mistakes for services.
|
||||||
|
names := map[string]struct{}{}
|
||||||
|
for _, name := range []string{
|
||||||
|
"networks", "volumes", "configs", "secrets", "services",
|
||||||
|
} {
|
||||||
|
names[name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := services[:0]
|
||||||
|
for _, service := range services {
|
||||||
|
if _, skip := names[service.Name]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, service)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// .env files
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// unquote strips the quoting Go string literals carry around literal values.
|
||||||
|
func unquote(value string) string {
|
||||||
|
if len(value) >= 2 {
|
||||||
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||||
|
(value[0] == '`' && value[len(value)-1] == '`') {
|
||||||
|
return value[1 : len(value)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// envFromExample reads KEY=VALUE pairs out of a .env or .env.example file.
|
||||||
|
func envFromExample(source string) []EnvVar {
|
||||||
|
var out []EnvVar
|
||||||
|
for _, raw := range strings.Split(source, "\n") {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, found := strings.Cut(line, "=")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" || !strings.ContainsAny(key, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, EnvVar{Name: key, Example: strings.Trim(strings.TrimSpace(value), `"'`), FromEnv: true})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Misc
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// portsIn extracts plausible TCP port literals from any file.
|
||||||
|
func portsIn(source string) []string {
|
||||||
|
if source == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, match := range portLiteral.FindAllStringSubmatch(source, -1) {
|
||||||
|
port, err := strconv.Atoi(match[1])
|
||||||
|
if err != nil || port < 1024 || port > 65535 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, match[1])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// portBearingFiles reports whether a file is a plausible source of a declared
|
||||||
|
// port. Scanning every source file for port literals produces noise (version
|
||||||
|
// numbers, row counts), so only operational files are considered.
|
||||||
|
func portBearingFiles(rel string) bool {
|
||||||
|
base := strings.ToLower(filepath.Base(rel))
|
||||||
|
switch {
|
||||||
|
case isDockerfile(rel), isComposeFile(rel), isHelmFile(rel):
|
||||||
|
return true
|
||||||
|
case base == "makefile", base == "makefile.am":
|
||||||
|
return true
|
||||||
|
case base == "readme.md":
|
||||||
|
return true
|
||||||
|
case strings.HasPrefix(base, ".env"):
|
||||||
|
return true
|
||||||
|
case strings.HasSuffix(base, ".service"), strings.HasSuffix(base, ".env.example"):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHelmFile reports whether a path is part of a Helm chart, anchored on the two
|
||||||
|
// files that define one.
|
||||||
|
func isHelmFile(rel string) bool {
|
||||||
|
base := strings.ToLower(filepath.Base(rel))
|
||||||
|
return base == "chart.yaml" || base == "values.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
// markdownTitle returns the first heading of a Markdown document.
|
||||||
|
func markdownTitle(source string) string {
|
||||||
|
for _, raw := range strings.Split(source, "\n") {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
return strings.TrimSpace(strings.TrimLeft(line, "#"))
|
||||||
|
}
|
||||||
|
if line != "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// findReadme locates the project's README, preferring the repository root.
|
||||||
|
func findReadme(p *Project) string {
|
||||||
|
for _, candidate := range []string{"README.md", "readme.md", "README.markdown", "Readme.md"} {
|
||||||
|
if info, err := os.Stat(filepath.Join(p.Path, candidate)); err == nil && !info.IsDir() {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, doc := range p.Docs {
|
||||||
|
if strings.EqualFold(filepath.Base(doc.Path), "readme.md") {
|
||||||
|
return doc.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarize produces the one-paragraph preamble shown above the audience tabs.
|
||||||
|
// It prefers the README's opening prose and falls back to a description derived
|
||||||
|
// from what the analyser found, so a project without a README still gets one.
|
||||||
|
func summarize(p *Project) string {
|
||||||
|
if p.Readme != "" {
|
||||||
|
if data, err := readCapped(p.Path+"/"+p.Readme, maxReadBytes); err == nil {
|
||||||
|
if summary := firstParagraph(string(data)); summary != "" {
|
||||||
|
return collapse(summary, 460)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []string{}
|
||||||
|
if module := primaryModule(p); module != "" {
|
||||||
|
parts = append(parts, "Go module `"+module+"`")
|
||||||
|
}
|
||||||
|
if p.Stats.GoFiles > 0 {
|
||||||
|
parts = append(parts, plural(p.Stats.GoFiles, "Go source file", "Go source files"))
|
||||||
|
}
|
||||||
|
if len(p.Routes) > 0 {
|
||||||
|
parts = append(parts, plural(len(p.Routes), "HTTP endpoint", "HTTP endpoints"))
|
||||||
|
}
|
||||||
|
if len(p.EnvVars) > 0 {
|
||||||
|
parts = append(parts, plural(len(p.EnvVars), "environment variable", "environment variables"))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "This project has no README yet. The sections below were derived from its structure on disk."
|
||||||
|
}
|
||||||
|
return "This project has no README yet. The analyser found " + joinList(parts) + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstParagraph returns the opening prose of a document: the first run of
|
||||||
|
// non-empty lines that is not a heading, badge, table or HTML block.
|
||||||
|
func firstParagraph(source string) string {
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(source, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "```") {
|
||||||
|
break // prose never starts after a code fence in these READMEs
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case trimmed == "":
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return strings.Join(lines, " ")
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(trimmed, "#"):
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(trimmed, "!"), strings.HasPrefix(trimmed, "[!"),
|
||||||
|
strings.HasPrefix(trimmed, "<"), strings.HasPrefix(trimmed, "|"):
|
||||||
|
continue // badge, HTML or table row
|
||||||
|
default:
|
||||||
|
lines = append(lines, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapse folds whitespace and truncates on a word boundary.
|
||||||
|
func collapse(text string, limit int) string {
|
||||||
|
fields := strings.Fields(text)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out := strings.Join(fields, " ")
|
||||||
|
if len(out) <= limit {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
cut := out[:limit]
|
||||||
|
if i := strings.LastIndexByte(cut, ' '); i > limit/2 {
|
||||||
|
cut = cut[:i]
|
||||||
|
}
|
||||||
|
return strings.TrimRight(cut, " ,;:.") + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryModule(p *Project) string {
|
||||||
|
for _, module := range p.Modules {
|
||||||
|
if module.Path != "" {
|
||||||
|
return module.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int, singular, many string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 " + singular
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n) + " " + many
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinList(parts []string) string {
|
||||||
|
switch len(parts) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
return parts[0]
|
||||||
|
case 2:
|
||||||
|
return parts[0] + " and " + parts[1]
|
||||||
|
default:
|
||||||
|
return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
143
backend/docgen/merge.go
Normal file
143
backend/docgen/merge.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package docgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// beginPrefix opens the machine-written region of an imported page. Everything
|
||||||
|
// between begin and EndMarker is replaced on every sync; anything outside it is
|
||||||
|
// preserved exactly as written.
|
||||||
|
const beginPrefix = "<!-- go-doc:generated:begin"
|
||||||
|
|
||||||
|
// EndMarker closes the machine-written region.
|
||||||
|
const EndMarker = "<!-- go-doc:generated:end -->"
|
||||||
|
|
||||||
|
// stampPattern matches the generated=<time> field of the opening marker. It is
|
||||||
|
// stripped before comparing an existing page with a freshly rendered one, because
|
||||||
|
// the timestamp changes on every run and would otherwise make every sync look
|
||||||
|
// like a real edit.
|
||||||
|
//
|
||||||
|
// The field has to be matched all the way to the "-->" that closes the comment:
|
||||||
|
// Timestamp writes "2006-01-02 15:04 UTC", so a pattern that stopped at the
|
||||||
|
// first space would leave the time behind and every sync that crossed a minute
|
||||||
|
// boundary would rewrite all four pages for nothing.
|
||||||
|
var stampPattern = regexp.MustCompile(`generated=[^\n]*?-->`)
|
||||||
|
|
||||||
|
// stampReplacement stands in for the stripped timestamp on both sides of the
|
||||||
|
// comparison, so an old page and a fresh render still compare equal.
|
||||||
|
const stampReplacement = "generated=STAMP-->"
|
||||||
|
|
||||||
|
// Status describes what Merge did, so callers can log precisely.
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
// Possible merge outcomes.
|
||||||
|
const (
|
||||||
|
// StatusCreated means no file existed, so the page was written whole.
|
||||||
|
StatusCreated Status = "created"
|
||||||
|
// StatusUpdated means the generated region was replaced in place.
|
||||||
|
StatusUpdated Status = "updated"
|
||||||
|
// StatusUnchanged means the generated region already matched.
|
||||||
|
StatusUnchanged Status = "unchanged"
|
||||||
|
// StatusSkipped means the file had no generated markers and force was off.
|
||||||
|
StatusSkipped Status = "skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Block locates the three boundaries of the generated region inside a page:
|
||||||
|
// the opening marker, the body that follows it, and the byte after the closing
|
||||||
|
// marker. All offsets are absolute and end-exclusive where it matters, so a
|
||||||
|
// caller can rebuild a page as prefix + replacement + suffix.
|
||||||
|
type Block struct {
|
||||||
|
// Marker is the index of the opening marker.
|
||||||
|
Marker int
|
||||||
|
// Body is the index of the first byte after the opening marker's line.
|
||||||
|
Body int
|
||||||
|
// End is the index of the first byte after the closing marker.
|
||||||
|
End int
|
||||||
|
Found bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindBlock locates the generated markers. Both must be present and the opening
|
||||||
|
// marker must sit at the start of a line, otherwise Found is false — which is
|
||||||
|
// the signal that a file was authored by hand and must not be clobbered.
|
||||||
|
func FindBlock(content string) Block {
|
||||||
|
start := strings.Index(content, beginPrefix)
|
||||||
|
if start < 0 {
|
||||||
|
return Block{}
|
||||||
|
}
|
||||||
|
// The marker must own its line: either it opens the file or a newline
|
||||||
|
// precedes it.
|
||||||
|
if start > 0 && content[start-1] != '\n' {
|
||||||
|
return Block{}
|
||||||
|
}
|
||||||
|
|
||||||
|
lineEnd := strings.IndexByte(content[start:], '\n')
|
||||||
|
if lineEnd < 0 {
|
||||||
|
return Block{}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := start + lineEnd + 1
|
||||||
|
|
||||||
|
rest := content[body:]
|
||||||
|
end := strings.Index(rest, EndMarker)
|
||||||
|
if end < 0 {
|
||||||
|
return Block{}
|
||||||
|
}
|
||||||
|
return Block{
|
||||||
|
Marker: start,
|
||||||
|
Body: body,
|
||||||
|
End: body + end + len(EndMarker),
|
||||||
|
Found: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge folds a freshly generated page into the existing file.
|
||||||
|
//
|
||||||
|
// Hand-written prose outside the markers survives untouched. A file with no
|
||||||
|
// markers at all is left alone unless force is set, so importing can never
|
||||||
|
// destroy a document a human wrote. An identical page is reported unchanged and
|
||||||
|
// returned verbatim, so a no-op sync does not rewrite the file or churn its
|
||||||
|
// modification time.
|
||||||
|
func Merge(existing string, generated string, force bool) (string, Status, error) {
|
||||||
|
if strings.TrimSpace(existing) == "" {
|
||||||
|
return generated, StatusCreated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
block := FindBlock(existing)
|
||||||
|
if !block.Found {
|
||||||
|
if !force {
|
||||||
|
return existing, StatusSkipped, nil
|
||||||
|
}
|
||||||
|
return generated, StatusUpdated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The replacement carries its own markers, so the preserved prefix stops
|
||||||
|
// before the old opening marker rather than after it.
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(existing[:block.Marker])
|
||||||
|
b.WriteString(generated)
|
||||||
|
b.WriteString(existing[block.End:])
|
||||||
|
|
||||||
|
merged := b.String()
|
||||||
|
if stampPattern.ReplaceAllString(merged, stampReplacement) ==
|
||||||
|
stampPattern.ReplaceAllString(existing, stampReplacement) {
|
||||||
|
return existing, StatusUnchanged, nil
|
||||||
|
}
|
||||||
|
return merged, StatusUpdated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap surrounds body with the generated markers, stamping provenance so a
|
||||||
|
// reader can tell which project and which source revision produced the page.
|
||||||
|
//
|
||||||
|
// The body is normalised to exactly one trailing newline so the closing marker
|
||||||
|
// always starts its own line, and no newline is emitted after that marker: the
|
||||||
|
// marker is the last thing Wrap writes, so replacing a region in place and
|
||||||
|
// keeping the following bytes of the file cannot double the trailing blank line.
|
||||||
|
func Wrap(source, signature, body string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s source=%s signature=%s generated=%s -->\n%s\n%s",
|
||||||
|
beginPrefix, source, signature, Timestamp(),
|
||||||
|
strings.TrimRight(body, "\n"), EndMarker,
|
||||||
|
)
|
||||||
|
}
|
||||||
565
backend/docgen/render.go
Normal file
565
backend/docgen/render.go
Normal file
|
|
@ -0,0 +1,565 @@
|
||||||
|
package docgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render produces the full Markdown page for a project, wrapped in generated
|
||||||
|
// markers. The output always carries the three level-2 audience headings that
|
||||||
|
// go-doc's reader keys off, so an imported page is immediately navigable.
|
||||||
|
func Render(p *Project) (string, error) {
|
||||||
|
view := buildView(p)
|
||||||
|
|
||||||
|
var b builder
|
||||||
|
b.h1(p.Name)
|
||||||
|
b.para(p.Summary)
|
||||||
|
b.table(
|
||||||
|
[]string{"", ""},
|
||||||
|
[]row{
|
||||||
|
{"Module", view.ModuleSummary},
|
||||||
|
{"Endpoints", view.EndpointCount},
|
||||||
|
{"Components", view.ComponentCount},
|
||||||
|
{"Tests", view.TestSummary},
|
||||||
|
{"Go lines", view.LineSummary},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
b.note("Everything between the generated markers is rewritten by every sync. " +
|
||||||
|
"Notes written outside the markers are kept.")
|
||||||
|
|
||||||
|
users(&b, view)
|
||||||
|
operators(&b, view)
|
||||||
|
developers(&b, view)
|
||||||
|
|
||||||
|
return Wrap(p.Name, p.Signature, strings.TrimRight(b.String(), "\n")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audience sections
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func users(b *builder, v docView) {
|
||||||
|
b.h2("For Users")
|
||||||
|
|
||||||
|
b.h3("What this application does")
|
||||||
|
b.para(v.Summary)
|
||||||
|
|
||||||
|
if v.HasReadme {
|
||||||
|
b.para(fmt.Sprintf(
|
||||||
|
"The project ships its own README at `%s`, which is the long form of this page. "+
|
||||||
|
"The three audience tabs split the same facts three ways so you only read the part "+
|
||||||
|
"that concerns you.", v.Readme))
|
||||||
|
} else {
|
||||||
|
b.para("This project ships no README, so the summary above was derived from the source " +
|
||||||
|
"tree. If that is wrong, write a README and re-run the sync.")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.h3("Reading this in go-doc")
|
||||||
|
b.para("Open the page and pick the tab that matches you:")
|
||||||
|
b.bullets([]string{
|
||||||
|
"**User** — you use the finished application. This section is all you need.",
|
||||||
|
"**Operator** — you run it. Configuration, ports and deployment notes are under that tab.",
|
||||||
|
"**Developer** — you integrate with it or change it. The API and layout tables are under that tab.",
|
||||||
|
})
|
||||||
|
b.para("The feedback panel stores a note per audience, tagged with your badge, so operators " +
|
||||||
|
"and developers can see which audience raised it.")
|
||||||
|
|
||||||
|
if len(v.Docs) > 0 {
|
||||||
|
b.h3("Where the original documentation lives")
|
||||||
|
b.para(fmt.Sprintf(
|
||||||
|
"This project already carries %s. They stay in the project repository: this page "+
|
||||||
|
"points at them instead of copying them, so there is always exactly one copy of "+
|
||||||
|
"the truth.", plural(len(v.Docs), "Markdown document", "Markdown documents")))
|
||||||
|
|
||||||
|
rows := make([]row, 0, len(v.Docs))
|
||||||
|
for _, doc := range v.Docs {
|
||||||
|
rows = append(rows, row{"`" + doc.Path + "`", fmt.Sprintf("%d bytes", doc.Bytes)})
|
||||||
|
}
|
||||||
|
b.table([]string{"Document", "Size"}, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func operators(b *builder, v docView) {
|
||||||
|
b.rule()
|
||||||
|
b.h2("For Operators")
|
||||||
|
|
||||||
|
b.h3("Running it locally")
|
||||||
|
if len(v.MakeTargets) > 0 {
|
||||||
|
b.para(fmt.Sprintf("The root Makefile defines %s:",
|
||||||
|
plural(len(v.MakeTargets), "target", "targets")))
|
||||||
|
b.bullets(backtickEach(v.MakeTargets, "make "))
|
||||||
|
} else {
|
||||||
|
b.para("This project has no root Makefile, so the binary or container is run directly.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(v.Compose) > 0 {
|
||||||
|
b.h3("Local containers")
|
||||||
|
b.para("The compose file defines these services:")
|
||||||
|
|
||||||
|
rows := make([]row, 0, len(v.Compose))
|
||||||
|
for _, service := range v.Compose {
|
||||||
|
image := "built locally"
|
||||||
|
if service.Image != "" {
|
||||||
|
image = "`" + service.Image + "`"
|
||||||
|
}
|
||||||
|
ports := "internal only"
|
||||||
|
if service.Ports != "" {
|
||||||
|
ports = service.Ports
|
||||||
|
}
|
||||||
|
rows = append(rows, row{service.Name, image, ports})
|
||||||
|
}
|
||||||
|
b.table([]string{"Service", "Image", "Published ports"}, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.Docker != nil {
|
||||||
|
b.h3("Image")
|
||||||
|
|
||||||
|
var sentence strings.Builder
|
||||||
|
fmt.Fprintf(&sentence, "`%s` builds from", v.Docker.File)
|
||||||
|
for i, base := range v.Docker.Bases {
|
||||||
|
if i > 0 {
|
||||||
|
sentence.WriteString(",")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sentence, " `%s`", base)
|
||||||
|
}
|
||||||
|
if len(v.Docker.Stages) > 0 {
|
||||||
|
fmt.Fprintf(&sentence, " in stages: %s", strings.Join(v.Docker.Stages, ", "))
|
||||||
|
}
|
||||||
|
if len(v.Docker.Exposed) > 0 {
|
||||||
|
fmt.Fprintf(&sentence, " and exposes %s", strings.Join(v.Docker.Exposed, ", "))
|
||||||
|
}
|
||||||
|
sentence.WriteString(".")
|
||||||
|
b.para(sentence.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
b.h3("Configuration")
|
||||||
|
if len(v.EnvVars) > 0 {
|
||||||
|
b.para("Every environment variable found in the source or in a `.env` file:")
|
||||||
|
|
||||||
|
rows := make([]row, 0, len(v.EnvVars))
|
||||||
|
for _, env := range v.EnvVars {
|
||||||
|
example := "—"
|
||||||
|
if env.Example != "" {
|
||||||
|
example = "`" + env.Example + "`"
|
||||||
|
}
|
||||||
|
source := "source"
|
||||||
|
if env.FromEnv {
|
||||||
|
source = ".env file"
|
||||||
|
}
|
||||||
|
rows = append(rows, row{"`" + env.Name + "`", example, source})
|
||||||
|
}
|
||||||
|
b.table([]string{"Variable", "Example", "Found in"}, rows)
|
||||||
|
} else {
|
||||||
|
b.para("No environment variables were found in the source or in a `.env` file.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(v.Flags) > 0 {
|
||||||
|
b.para("Command-line flags override the environment where both exist:")
|
||||||
|
|
||||||
|
rows := make([]row, 0, len(v.Flags))
|
||||||
|
for _, flag := range v.Flags {
|
||||||
|
decl := "-" + flag.Name
|
||||||
|
if flag.Default != "" {
|
||||||
|
decl += "=" + flag.Default
|
||||||
|
}
|
||||||
|
def := "—"
|
||||||
|
if flag.Default != "" {
|
||||||
|
def = "`" + flag.Default + "`"
|
||||||
|
}
|
||||||
|
usage := "—"
|
||||||
|
if flag.Usage != "" {
|
||||||
|
usage = collapse(flag.Usage, 90)
|
||||||
|
}
|
||||||
|
rows = append(rows, row{"`" + decl + "`", def, usage})
|
||||||
|
}
|
||||||
|
b.table([]string{"Flag", "Default", "Purpose"}, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.h3("Network surface")
|
||||||
|
if len(v.Ports) > 0 {
|
||||||
|
quoted := make([]string, 0, len(v.Ports))
|
||||||
|
for _, port := range v.Ports {
|
||||||
|
quoted = append(quoted, "`"+port+"`")
|
||||||
|
}
|
||||||
|
b.para("Ports referenced across the project: " + strings.Join(quoted, ", ") + ".")
|
||||||
|
} else {
|
||||||
|
b.para("No explicit port declaration was found. Check the README and the compose file " +
|
||||||
|
"before assuming a default.")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.h3("Deployment artefacts")
|
||||||
|
if len(v.HelmCharts) > 0 {
|
||||||
|
b.para(fmt.Sprintf("Helm %s found:",
|
||||||
|
plural(len(v.HelmCharts), "chart", "charts")))
|
||||||
|
b.bullets(backtickEach(v.HelmCharts, ""))
|
||||||
|
} else {
|
||||||
|
b.para("No Helm chart was found, so deployment is expected to be plain Docker or a " +
|
||||||
|
"managed platform.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(v.OperatorDocs) > 0 {
|
||||||
|
b.h3("Operations documentation that already exists")
|
||||||
|
b.para("Read these in place rather than duplicating them here:")
|
||||||
|
b.bullets(docBullets(v.OperatorDocs))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, note := range v.Notes {
|
||||||
|
b.note(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func developers(b *builder, v docView) {
|
||||||
|
b.rule()
|
||||||
|
b.h2("For Developers")
|
||||||
|
|
||||||
|
b.h3("Module layout")
|
||||||
|
if len(v.ModuleSummaries) > 0 {
|
||||||
|
b.bullets(v.ModuleSummaries)
|
||||||
|
} else {
|
||||||
|
b.para("No go.mod in this tree: the project may be vendored, a workspace member, or " +
|
||||||
|
"not Go at all.")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.h3("HTTP API")
|
||||||
|
if len(v.Routes) > 0 {
|
||||||
|
b.para("Every route registration found in the source:")
|
||||||
|
|
||||||
|
rows := make([]row, 0, len(v.Routes))
|
||||||
|
for _, route := range v.Routes {
|
||||||
|
method := "ANY"
|
||||||
|
if route.Method != "" {
|
||||||
|
method = "`" + route.Method + "`"
|
||||||
|
}
|
||||||
|
handler := "—"
|
||||||
|
if route.Handler != "" {
|
||||||
|
handler = "`" + route.Handler + "`"
|
||||||
|
}
|
||||||
|
rows = append(rows, row{method, "`" + route.Path + "`", handler})
|
||||||
|
}
|
||||||
|
b.table([]string{"Method", "Path", "Handler"}, rows)
|
||||||
|
} else {
|
||||||
|
b.para("No route registrations were detected, which is expected for a CLI, a library " +
|
||||||
|
"or a worker.")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.h3("Direct dependencies")
|
||||||
|
if deps := v.DirectDeps(); len(deps) > 0 {
|
||||||
|
items := make([]string, 0, len(deps))
|
||||||
|
for _, dep := range deps {
|
||||||
|
items = append(items, "`"+dep.Path+"` "+dep.Version)
|
||||||
|
}
|
||||||
|
b.bullets(items)
|
||||||
|
} else {
|
||||||
|
b.para("No third-party dependencies are declared.")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.h3("Directory map")
|
||||||
|
if len(v.Dirs) > 0 {
|
||||||
|
rows := make([]row, 0, len(v.Dirs))
|
||||||
|
for _, dir := range v.Dirs {
|
||||||
|
rows = append(rows, row{
|
||||||
|
"`" + dir.Name + "/`",
|
||||||
|
fmt.Sprintf("%d", dir.Files),
|
||||||
|
fmt.Sprintf("%d", dir.Lines),
|
||||||
|
directoryPurpose(dir.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
b.table([]string{"Directory", "Files", "Lines", "Likely purpose"}, rows)
|
||||||
|
} else {
|
||||||
|
b.para("No first-level directories were found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.h3("Languages")
|
||||||
|
if len(v.Languages) > 0 {
|
||||||
|
items := make([]string, 0, len(v.Languages))
|
||||||
|
for _, lang := range v.Languages {
|
||||||
|
items = append(items, fmt.Sprintf("**%s** — %s, %d lines", lang.Name,
|
||||||
|
plural(lang.Files, "file", "files"), lang.Lines))
|
||||||
|
}
|
||||||
|
b.bullets(items)
|
||||||
|
} else {
|
||||||
|
b.para("No source files were recognised.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(v.DeveloperDocs) > 0 {
|
||||||
|
b.h3("Developer documentation that already exists")
|
||||||
|
b.bullets(docBullets(v.DeveloperDocs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Markdown builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// builder assembles Markdown with predictable spacing. Block elements ask for a
|
||||||
|
// separator instead of writing blank lines by hand, so every block is preceded
|
||||||
|
// by exactly one blank line no matter which branch produced it — the failure
|
||||||
|
// mode this replaces was headings glued to the paragraph above them.
|
||||||
|
type builder struct {
|
||||||
|
out strings.Builder
|
||||||
|
lastBlank bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *builder) String() string { return b.out.String() }
|
||||||
|
|
||||||
|
// ensureBlank separates the next line from the previous one unless it already is.
|
||||||
|
func (b *builder) ensureBlank() {
|
||||||
|
if b.out.Len() == 0 || b.lastBlank {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.out.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *builder) line(text string) {
|
||||||
|
b.out.WriteString(text)
|
||||||
|
b.out.WriteString("\n")
|
||||||
|
b.lastBlank = text == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *builder) h1(text string) { b.ensureBlank(); b.line("# " + text) }
|
||||||
|
|
||||||
|
func (b *builder) h2(text string) { b.ensureBlank(); b.line("## " + text) }
|
||||||
|
|
||||||
|
func (b *builder) h3(text string) { b.ensureBlank(); b.line("### " + text) }
|
||||||
|
|
||||||
|
func (b *builder) para(text string) { b.ensureBlank(); b.line(text) }
|
||||||
|
|
||||||
|
func (b *builder) note(text string) { b.ensureBlank(); b.line("> " + text) }
|
||||||
|
|
||||||
|
// rule writes a horizontal rule, which always gets its own block.
|
||||||
|
func (b *builder) rule() { b.ensureBlank(); b.line("---") }
|
||||||
|
|
||||||
|
// bullets writes a tight list: the separator goes before the list, never between
|
||||||
|
// its items.
|
||||||
|
func (b *builder) bullets(items []string) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.ensureBlank()
|
||||||
|
for _, item := range items {
|
||||||
|
b.line("- " + item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// row is one Markdown table row.
|
||||||
|
type row []string
|
||||||
|
|
||||||
|
// table writes a GitHub-flavoured table. Cell content is emitted verbatim: every
|
||||||
|
// caller has already added its own backticks, so the builder must not guess.
|
||||||
|
func (b *builder) table(headers []string, rows []row) {
|
||||||
|
b.ensureBlank()
|
||||||
|
|
||||||
|
cells := make([]string, len(headers))
|
||||||
|
for i, header := range headers {
|
||||||
|
cells[i] = header
|
||||||
|
}
|
||||||
|
b.line("| " + strings.Join(cells, " | ") + " |")
|
||||||
|
|
||||||
|
separators := make([]string, len(headers))
|
||||||
|
for i := range separators {
|
||||||
|
separators[i] = "---"
|
||||||
|
}
|
||||||
|
b.line("| " + strings.Join(separators, " | ") + " |")
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
b.line("| " + strings.Join(r, " | ") + " |")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backtickEach renders values as a list of code-formatted bullet items.
|
||||||
|
func backtickEach(values []string, prefix string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
out = append(out, "`"+prefix+value+"`")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// docBullets renders discovered documents as code-formatted bullets with their
|
||||||
|
// titles when they have one.
|
||||||
|
func docBullets(docs []DocFile) []string {
|
||||||
|
out := make([]string, 0, len(docs))
|
||||||
|
for _, doc := range docs {
|
||||||
|
item := "`" + doc.Path + "`"
|
||||||
|
if doc.Title != "" {
|
||||||
|
item += " — " + doc.Title
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// View model
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// docView is the pre-computed, presentation-ready view of a project.
|
||||||
|
type docView struct {
|
||||||
|
*Project
|
||||||
|
|
||||||
|
ModuleSummary string
|
||||||
|
EndpointCount string
|
||||||
|
ComponentCount string
|
||||||
|
TestSummary string
|
||||||
|
LineSummary string
|
||||||
|
HasReadme bool
|
||||||
|
OperatorDocs []DocFile
|
||||||
|
DeveloperDocs []DocFile
|
||||||
|
ModuleSummaries []string
|
||||||
|
Notes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectDeps flattens the direct requirements of every module in the tree.
|
||||||
|
func (v docView) DirectDeps() []Dependency {
|
||||||
|
var out []Dependency
|
||||||
|
for _, m := range v.Modules {
|
||||||
|
out = append(out, m.Direct...)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildView(p *Project) docView {
|
||||||
|
v := docView{Project: p}
|
||||||
|
|
||||||
|
switch module := primaryModule(p); {
|
||||||
|
case module == "":
|
||||||
|
v.ModuleSummary = "no go.mod in this tree"
|
||||||
|
default:
|
||||||
|
parts := []string{module}
|
||||||
|
for _, m := range p.Modules {
|
||||||
|
if m.GoVersion != "" {
|
||||||
|
parts = append(parts, "go "+m.GoVersion)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, plural(p.Stats.GoFiles, "Go file", "Go files"))
|
||||||
|
v.ModuleSummary = strings.Join(parts, " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
v.EndpointCount = plural(len(p.Routes), "endpoint", "endpoints")
|
||||||
|
v.ComponentCount = plural(p.Stats.Components, "component directory", "component directories")
|
||||||
|
v.LineSummary = plural(p.Stats.TotalLines, "line", "lines")
|
||||||
|
v.HasReadme = p.Readme != ""
|
||||||
|
|
||||||
|
if p.Stats.TestFuncs > 0 {
|
||||||
|
v.TestSummary = fmt.Sprintf("%d in %s", p.Stats.TestFuncs,
|
||||||
|
plural(p.Stats.TestFiles, "file", "files"))
|
||||||
|
} else {
|
||||||
|
v.TestSummary = "none detected"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route existing project docs under the audience that most likely owns them,
|
||||||
|
// so the generated page points rather than duplicates.
|
||||||
|
for _, doc := range p.Docs {
|
||||||
|
lower := strings.ToLower(doc.Path)
|
||||||
|
switch {
|
||||||
|
case containsAny(lower, "operat", "runbook", "deploy", "infra", "install", "troubleshoot"):
|
||||||
|
v.OperatorDocs = append(v.OperatorDocs, doc)
|
||||||
|
case containsAny(lower, "architect", "develop", "api", "integration", "contract", "coding", "design"):
|
||||||
|
v.DeveloperDocs = append(v.DeveloperDocs, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range p.Modules {
|
||||||
|
var summary strings.Builder
|
||||||
|
summary.WriteString(m.File)
|
||||||
|
if m.Path != "" {
|
||||||
|
summary.WriteString(" — module " + m.Path)
|
||||||
|
}
|
||||||
|
if m.GoVersion != "" {
|
||||||
|
summary.WriteString(", go " + m.GoVersion)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&summary, ", %d direct and %d indirect dependencies", len(m.Direct), m.Indirect)
|
||||||
|
v.ModuleSummaries = append(v.ModuleSummaries, summary.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything a reader should know before trusting the generated content.
|
||||||
|
if !v.HasReadme {
|
||||||
|
v.Notes = append(v.Notes,
|
||||||
|
"This project ships no README, so the summary was derived from the tree layout. Add one and re-run the sync.")
|
||||||
|
}
|
||||||
|
if len(p.Modules) == 0 {
|
||||||
|
v.Notes = append(v.Notes, "No go.mod was found, so this tree was analysed as plain source files.")
|
||||||
|
}
|
||||||
|
if p.Docker == nil {
|
||||||
|
v.Notes = append(v.Notes, "No Dockerfile was found at any level.")
|
||||||
|
}
|
||||||
|
if len(p.Routes) == 0 && len(p.Modules) > 0 {
|
||||||
|
v.Notes = append(v.Notes,
|
||||||
|
"No HTTP route registrations were detected. If this is a server, the API table is incomplete — check for routes built dynamically.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(haystack string, needles ...string) bool {
|
||||||
|
for _, needle := range needles {
|
||||||
|
if strings.Contains(haystack, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// directoryPurpose guesses what a top-level directory is for. It is a heuristic
|
||||||
|
// on names only, and it admits ignorance rather than inventing an answer.
|
||||||
|
func directoryPurpose(name string) string {
|
||||||
|
switch strings.ToLower(name) {
|
||||||
|
case "internal":
|
||||||
|
return "unimportable application internals"
|
||||||
|
case "pkg":
|
||||||
|
return "public library packages"
|
||||||
|
case "cmd", "bin", "main":
|
||||||
|
return "binary entry points"
|
||||||
|
case "api":
|
||||||
|
return "HTTP handlers and routing"
|
||||||
|
case "server":
|
||||||
|
return "server entry point"
|
||||||
|
case "services", "service":
|
||||||
|
return "domain services"
|
||||||
|
case "web", "ui", "frontend", "client":
|
||||||
|
return "frontend assets"
|
||||||
|
case "bdd", "e2e", "tests", "test", "features":
|
||||||
|
return "tests"
|
||||||
|
case "deploy", "deployment", "charts", "helm", "k8s", "kubernetes":
|
||||||
|
return "deployment manifests"
|
||||||
|
case "docker", "scripts", "script", "hack":
|
||||||
|
return "operational tooling"
|
||||||
|
case "docs", "documentation":
|
||||||
|
return "project documentation"
|
||||||
|
case "migrations":
|
||||||
|
return "schema migrations"
|
||||||
|
case "contracts", "openapi", "proto", "schemas":
|
||||||
|
return "shared schemas or contracts"
|
||||||
|
case "data":
|
||||||
|
return "flat-file data"
|
||||||
|
case "dist", "build", "out":
|
||||||
|
return "build output"
|
||||||
|
case "examples", "example":
|
||||||
|
return "worked examples"
|
||||||
|
case "auth":
|
||||||
|
return "authentication"
|
||||||
|
case "config":
|
||||||
|
return "configuration loading"
|
||||||
|
case "security":
|
||||||
|
return "security controls"
|
||||||
|
case "store", "storage":
|
||||||
|
return "persistence layer"
|
||||||
|
case "database":
|
||||||
|
return "database access"
|
||||||
|
case "models", "model", "schema":
|
||||||
|
return "domain models"
|
||||||
|
case "middleware":
|
||||||
|
return "HTTP middleware"
|
||||||
|
case "templates", "views":
|
||||||
|
return "render templates"
|
||||||
|
case "assets", "static":
|
||||||
|
return "static assets"
|
||||||
|
case "feedback", "comments":
|
||||||
|
return "user feedback"
|
||||||
|
default:
|
||||||
|
return "unknown — inspect directly"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/go-doc
Executable file
BIN
backend/go-doc
Executable file
Binary file not shown.
141
backend/go-loose/client/client.go
Normal file
141
backend/go-loose/client/client.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
// Package client provides fail-closed API-key authorization middleware for Go services.
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultHeader = "X-API-Key"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string
|
||||||
|
Tenant string
|
||||||
|
Application string
|
||||||
|
Header string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
tenant string
|
||||||
|
application string
|
||||||
|
header string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
TenantSlug string `json:"tenant_slug"`
|
||||||
|
ApplicationID string `json:"application_id"`
|
||||||
|
ApplicationSlug string `json:"application_slug"`
|
||||||
|
APIKeyID string `json:"api_key_id"`
|
||||||
|
APIKeyName string `json:"api_key_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type authorizationResponse struct {
|
||||||
|
Allowed bool `json:"allowed"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Principal
|
||||||
|
}
|
||||||
|
|
||||||
|
const XTenantId = "X-Tenant-Id"
|
||||||
|
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
func New(cfg Config) (*Client, error) {
|
||||||
|
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Application) == "" {
|
||||||
|
return nil, errors.New("go-loose client requires BaseURL and Application")
|
||||||
|
}
|
||||||
|
if cfg.Header == "" {
|
||||||
|
cfg.Header = DefaultHeader
|
||||||
|
}
|
||||||
|
if cfg.HTTPClient == nil {
|
||||||
|
cfg.HTTPClient = &http.Client{Timeout: 3 * time.Second}
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(cfg.BaseURL, "/"), tenant: cfg.Tenant,
|
||||||
|
application: cfg.Application, header: cfg.Header, httpClient: cfg.HTTPClient,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Authorize(ctx context.Context, apiKey, method, requestPath, host string) (Principal, error) {
|
||||||
|
payload, err := json.Marshal(map[string]string{
|
||||||
|
"tenant": c.tenant, "application": c.application, "method": method,
|
||||||
|
"path": requestPath, "host": stripPort(host),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, fmt.Errorf("encode authorization request: %w", err)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/authorize", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, fmt.Errorf("create authorization request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set(c.header, apiKey)
|
||||||
|
response, err := c.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, fmt.Errorf("authorize with Go Loose: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, fmt.Errorf("read authorization response: %w", err)
|
||||||
|
}
|
||||||
|
var result authorizationResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return Principal{}, fmt.Errorf("decode authorization response (HTTP %d): %w", response.StatusCode, err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK || !result.Allowed {
|
||||||
|
if result.Reason == "" {
|
||||||
|
result.Reason = http.StatusText(response.StatusCode)
|
||||||
|
}
|
||||||
|
return Principal{}, fmt.Errorf("request denied by Go Loose: %s", result.Reason)
|
||||||
|
}
|
||||||
|
return result.Principal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware rejects missing, invalid, or unverifiable keys and places the
|
||||||
|
// authorized Principal in the request context.
|
||||||
|
func (c *Client) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
apiKey := strings.TrimSpace(r.Header.Get(c.header))
|
||||||
|
if apiKey == "" {
|
||||||
|
writeMiddlewareError(w, http.StatusUnauthorized, "missing API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, err := c.Authorize(r.Context(), apiKey, r.Method, r.URL.Path, r.Host)
|
||||||
|
if err != nil {
|
||||||
|
writeMiddlewareError(w, http.StatusForbidden, "API key not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Header.Del(c.header)
|
||||||
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKey{}, principal)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
|
||||||
|
principal, ok := ctx.Value(contextKey{}).(Principal)
|
||||||
|
return principal, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripPort(host string) string {
|
||||||
|
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
return strings.ToLower(parsed)
|
||||||
|
}
|
||||||
|
return strings.ToLower(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMiddlewareError(w http.ResponseWriter, status int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||||
|
}
|
||||||
54
backend/go-loose/client/client_test.go
Normal file
54
backend/go-loose/client/client_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMiddlewareAuthorizesAndAddsPrincipal(t *testing.T) {
|
||||||
|
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get(DefaultHeader) != "gl_test_key_that_is_long_enough_for_client" {
|
||||||
|
t.Errorf("unexpected API key")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"allowed":true,"tenant_id":"tenant-1","tenant_slug":"nmbs","application_id":"app-1","application_slug":"guess","api_key_id":"key-1","api_key_name":"test"}`))
|
||||||
|
}))
|
||||||
|
defer authorizationServer.Close()
|
||||||
|
|
||||||
|
client, err := New(Config{BaseURL: authorizationServer.URL, Tenant: "nmbs", Application: "guess"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
handler := client.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := PrincipalFromContext(r.Context())
|
||||||
|
if !ok || principal.TenantSlug != "nmbs" {
|
||||||
|
t.Fatalf("missing principal: %#v", principal)
|
||||||
|
}
|
||||||
|
if r.Header.Get(DefaultHeader) != "" {
|
||||||
|
t.Fatal("middleware must not forward the credential")
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "http://nmbs.guess.local/guesses", nil)
|
||||||
|
request.Header.Set(DefaultHeader, "gl_test_key_that_is_long_enough_for_client")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddlewareFailsClosedWithoutKey(t *testing.T) {
|
||||||
|
client, err := New(Config{BaseURL: "http://go-loose", Application: "guess"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
handler := client.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if recorder.Code != http.StatusUnauthorized || called {
|
||||||
|
t.Fatalf("status = %d, called = %v", recorder.Code, called)
|
||||||
|
}
|
||||||
|
}
|
||||||
237
backend/go-loose/client/webauth.go
Normal file
237
backend/go-loose/client/webauth.go
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSessionCookie = "go_loose_user_session"
|
||||||
|
stateCookie = "go_loose_login_state"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BrowserAuthConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
RedirectURL string
|
||||||
|
AfterLoginURL string
|
||||||
|
AfterLogoutURL string
|
||||||
|
LoginPath string
|
||||||
|
CookieName string
|
||||||
|
CookieSecure bool
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserAuth struct {
|
||||||
|
config BrowserAuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
TenantSlug string `json:"tenant_slug"`
|
||||||
|
ApplicationID string `json:"application_id"`
|
||||||
|
Application string `json:"application"`
|
||||||
|
// Role is the user's membership role in the owning tenant, for example
|
||||||
|
// user, operator, developer, admin, or owner.
|
||||||
|
Role string `json:"role"`
|
||||||
|
// Permissions lists the capability names granted by Role, such as
|
||||||
|
// "console.read", "keys.manage", or "profile.edit". Gate application
|
||||||
|
// features on these instead of comparing role names.
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can reports whether the role behind this session grants a capability.
|
||||||
|
func (u User) Can(permission string) bool {
|
||||||
|
for _, granted := range u.Permissions {
|
||||||
|
if granted == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type userContextKey struct{}
|
||||||
|
|
||||||
|
func NewBrowserAuth(config BrowserAuthConfig) (*BrowserAuth, error) {
|
||||||
|
if config.BaseURL == "" || config.ClientID == "" || config.ClientSecret == "" || config.RedirectURL == "" {
|
||||||
|
return nil, errors.New("browser auth requires BaseURL, ClientID, ClientSecret, and RedirectURL")
|
||||||
|
}
|
||||||
|
redirect, err := url.Parse(config.RedirectURL)
|
||||||
|
if err != nil || !redirect.IsAbs() {
|
||||||
|
return nil, errors.New("RedirectURL must be absolute")
|
||||||
|
}
|
||||||
|
config.BaseURL = strings.TrimRight(config.BaseURL, "/")
|
||||||
|
if config.AfterLoginURL == "" {
|
||||||
|
config.AfterLoginURL = "/"
|
||||||
|
}
|
||||||
|
if config.AfterLogoutURL == "" {
|
||||||
|
config.AfterLogoutURL = "/"
|
||||||
|
}
|
||||||
|
if config.LoginPath == "" {
|
||||||
|
config.LoginPath = "/login"
|
||||||
|
}
|
||||||
|
if config.CookieName == "" {
|
||||||
|
config.CookieName = defaultSessionCookie
|
||||||
|
}
|
||||||
|
if config.HTTPClient == nil {
|
||||||
|
config.HTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||||
|
}
|
||||||
|
return &BrowserAuth{config: config}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BrowserAuth) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state, err := randomValue(24)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not start login", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: stateCookie, Value: state, Path: callbackPath(a.config.RedirectURL),
|
||||||
|
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
|
||||||
|
})
|
||||||
|
query := url.Values{
|
||||||
|
"response_type": {"code"}, "client_id": {a.config.ClientID},
|
||||||
|
"redirect_uri": {a.config.RedirectURL}, "state": {state},
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.config.BaseURL+"/connect/authorize?"+query.Encode(), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BrowserAuth) CallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state, err := r.Cookie(stateCookie)
|
||||||
|
if err != nil || state.Value == "" || subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 {
|
||||||
|
http.Error(w, "invalid login state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "authorization code missing", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"}, "code": {code},
|
||||||
|
"redirect_uri": {a.config.RedirectURL},
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.BaseURL+"/connect/token", strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not complete login", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.SetBasicAuth(a.config.ClientID, a.config.ClientSecret)
|
||||||
|
response, err := a.config.HTTPClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "identity service unavailable", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
var tokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&tokenResponse); err != nil || response.StatusCode != http.StatusOK || tokenResponse.AccessToken == "" {
|
||||||
|
http.Error(w, "authorization code exchange failed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: a.config.CookieName, Value: tokenResponse.AccessToken, Path: "/",
|
||||||
|
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: tokenResponse.ExpiresIn,
|
||||||
|
})
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: callbackPath(a.config.RedirectURL), MaxAge: -1, HttpOnly: true})
|
||||||
|
http.Redirect(w, r, a.config.AfterLoginURL, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BrowserAuth) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var revokeErr error
|
||||||
|
if cookie, err := r.Cookie(a.config.CookieName); err == nil {
|
||||||
|
request, requestErr := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.BaseURL+"/connect/logout", nil)
|
||||||
|
if requestErr != nil {
|
||||||
|
revokeErr = requestErr
|
||||||
|
} else {
|
||||||
|
request.Header.Set("Authorization", "Bearer "+cookie.Value)
|
||||||
|
response, responseErr := a.config.HTTPClient.Do(request)
|
||||||
|
if responseErr != nil {
|
||||||
|
revokeErr = responseErr
|
||||||
|
} else {
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusNoContent {
|
||||||
|
revokeErr = fmt.Errorf("Go Loose logout returned HTTP %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: a.config.CookieName, Value: "", Path: "/", MaxAge: -1,
|
||||||
|
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
if revokeErr != nil {
|
||||||
|
http.Error(w, "local session cleared, but central session revocation failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.config.AfterLogoutURL, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BrowserAuth) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(a.config.CookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
http.Redirect(w, r, a.config.LoginPath, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.config.BaseURL+"/connect/userinfo", nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not validate session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+cookie.Value)
|
||||||
|
response, err := a.config.HTTPClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
var user User
|
||||||
|
if response.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&user) != nil {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: a.config.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||||
|
http.Redirect(w, r, a.config.LoginPath, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey{}, user)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserFromContext(ctx context.Context) (User, bool) {
|
||||||
|
user, ok := ctx.Value(userContextKey{}).(User)
|
||||||
|
return user, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomValue(size int) (string, error) {
|
||||||
|
value := make([]byte, size)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return "", fmt.Errorf("generate random value: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func callbackPath(rawURL string) string {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Path == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return parsed.Path
|
||||||
|
}
|
||||||
101
backend/go-loose/client/webauth_test.go
Normal file
101
backend/go-loose/client/webauth_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBrowserLoginRedirect(t *testing.T) {
|
||||||
|
auth, err := NewBrowserAuth(BrowserAuthConfig{
|
||||||
|
BaseURL: "https://nmbs.goloose.local", ClientID: "client",
|
||||||
|
ClientSecret: "secret", RedirectURL: "https://nmbs.guess.local/auth/callback",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBrowserAuth() error = %v", err)
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
auth.LoginHandler(recorder, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||||
|
if recorder.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d", recorder.Code)
|
||||||
|
}
|
||||||
|
target, err := url.Parse(recorder.Header().Get("Location"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse location: %v", err)
|
||||||
|
}
|
||||||
|
if target.Path != "/connect/authorize" || target.Query().Get("client_id") != "client" || target.Query().Get("state") == "" {
|
||||||
|
t.Fatalf("unexpected redirect: %s", target)
|
||||||
|
}
|
||||||
|
if len(recorder.Result().Cookies()) != 1 || !recorder.Result().Cookies()[0].HttpOnly {
|
||||||
|
t.Fatal("expected an HTTP-only state cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowserMiddlewareAddsUser(t *testing.T) {
|
||||||
|
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer session-token" {
|
||||||
|
t.Fatal("missing bearer token")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"id":"user-1","email":"user@example.com","tenant_slug":"nmbs","application":"guess"}`))
|
||||||
|
}))
|
||||||
|
defer identity.Close()
|
||||||
|
auth, err := NewBrowserAuth(BrowserAuthConfig{
|
||||||
|
BaseURL: identity.URL, ClientID: "client", ClientSecret: "secret",
|
||||||
|
RedirectURL: "http://localhost/auth/callback",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBrowserAuth() error = %v", err)
|
||||||
|
}
|
||||||
|
handler := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := UserFromContext(r.Context())
|
||||||
|
if !ok || user.Email != "user@example.com" {
|
||||||
|
t.Fatalf("unexpected user: %#v", user)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "http://localhost/private", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: defaultSessionCookie, Value: "session-token"})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowserCallbackUsesBasicClientAuthentication(t *testing.T) {
|
||||||
|
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientID, secret, ok := r.BasicAuth()
|
||||||
|
if !ok || clientID != "client-id" || secret != "client-secret" {
|
||||||
|
t.Fatalf("unexpected client authentication: %q, %q, %v", clientID, secret, ok)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"access_token":"session-token","expires_in":3600}`))
|
||||||
|
}))
|
||||||
|
defer identity.Close()
|
||||||
|
auth, err := NewBrowserAuth(BrowserAuthConfig{
|
||||||
|
BaseURL: identity.URL, ClientID: "client-id", ClientSecret: "client-secret",
|
||||||
|
RedirectURL: "http://localhost/auth/callback",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBrowserAuth() error = %v", err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/auth/callback?code=one-time-code&state=login-state", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: stateCookie, Value: "login-state"})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
auth.CallbackHandler(recorder, request)
|
||||||
|
if recorder.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
var session *http.Cookie
|
||||||
|
for _, cookie := range recorder.Result().Cookies() {
|
||||||
|
if cookie.Name == defaultSessionCookie {
|
||||||
|
session = cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session == nil || session.Value != "session-token" || !session.HttpOnly {
|
||||||
|
t.Fatalf("unexpected session cookie: %#v", session)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
backend/go.mod
Normal file
5
backend/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module go-doc
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1
|
||||||
1
backend/go.sum
Normal file
1
backend/go.sum
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
|
||||||
226
backend/main.go
Normal file
226
backend/main.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
// Command godoc runs the go-doc documentation and feedback platform.
|
||||||
|
//
|
||||||
|
// It serves the REST API on /api/* and, when the React bundle has been compiled
|
||||||
|
// in via `make build`, serves the single-page application on every other path.
|
||||||
|
// Shutdown is graceful: SIGINT/SIGTERM stop accepting connections, let in-flight
|
||||||
|
// requests finish, and close the process cleanly.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-doc/go-loose/client"
|
||||||
|
"go-doc/router"
|
||||||
|
"go-doc/storage"
|
||||||
|
"go-doc/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAddr = "0.0.0.0:8080"
|
||||||
|
readHeaderTimeout = 10 * time.Second
|
||||||
|
readTimeout = 30 * time.Second
|
||||||
|
writeTimeout = 60 * time.Second
|
||||||
|
idleTimeout = 120 * time.Second
|
||||||
|
shutdownGracePeriod = 10 * time.Second
|
||||||
|
shutdownSignalWindow = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
slog.Error("fatal", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
var (
|
||||||
|
addr = flag.String("addr", envOr("GO_DOC_ADDR", defaultAddr), "host:port the API listens on")
|
||||||
|
dataDir = flag.String("data", "", "flat-file data directory (defaults to GO_DOC_DATA_DIR or auto-discovery)")
|
||||||
|
origins = flag.String("cors", envOr("GO_DOC_CORS", ""), "comma-separated allowed origins (default: *)")
|
||||||
|
logLevel = flag.String("log-level", envOr("GO_DOC_LOG_LEVEL", "info"), "debug, info, warn or error")
|
||||||
|
logFormat = flag.String("log-format", envOr("GO_DOC_LOG_FORMAT", "text"), "text or json")
|
||||||
|
looseBaseURL = flag.String("loose-base-url", envOr("GO_LOOSE_BASE_URL", ""), "go-loose base URL for authentication (e.g., https://auth.example.com)")
|
||||||
|
looseClientID = flag.String("loose-client-id", envOr("GO_LOOSE_CLIENT_ID", ""), "go-loose client ID for OAuth")
|
||||||
|
looseSecret = flag.String("loose-client-secret", envOr("GO_LOOSE_CLIENT_SECRET", ""), "go-loose client secret for OAuth")
|
||||||
|
looseRedirect = flag.String("loose-redirect-url", envOr("GO_LOOSE_REDIRECT_URL", ""), "go-loose redirect URL after login (e.g., https://doc.example.com/auth/callback)")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
logger := newLogger(*logLevel, *logFormat)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
root := *dataDir
|
||||||
|
if root == "" {
|
||||||
|
root = storage.ResolveRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
engine, err := storage.New(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("initialise storage: %w", err)
|
||||||
|
}
|
||||||
|
logger.Info("storage ready",
|
||||||
|
"data_root", engine.Root(),
|
||||||
|
"docs", engine.DocsDir(),
|
||||||
|
"comments", engine.CommentsDir(),
|
||||||
|
)
|
||||||
|
|
||||||
|
staticFS := webui.FS()
|
||||||
|
if staticFS == nil {
|
||||||
|
logger.Warn("no embedded frontend bundle found; serving API only",
|
||||||
|
"hint", "run `make frontend-run` for the Vite dev server or `make build` to embed assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize go-loose browser auth if configured
|
||||||
|
var browserAuth *client.BrowserAuth
|
||||||
|
if *looseBaseURL != "" && *looseClientID != "" && *looseSecret != "" && *looseRedirect != "" {
|
||||||
|
browserAuthConfig := client.BrowserAuthConfig{
|
||||||
|
BaseURL: *looseBaseURL,
|
||||||
|
ClientID: *looseClientID,
|
||||||
|
ClientSecret: *looseSecret,
|
||||||
|
RedirectURL: *looseRedirect,
|
||||||
|
AfterLoginURL: "/",
|
||||||
|
AfterLogoutURL: "/",
|
||||||
|
LoginPath: "/auth/login",
|
||||||
|
CookieName: "go_doc_session",
|
||||||
|
CookieSecure: true,
|
||||||
|
}
|
||||||
|
browserAuth, err = client.NewBrowserAuth(browserAuthConfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("go-loose browser auth not configured", "error", err)
|
||||||
|
} else {
|
||||||
|
logger.Info("go-loose browser auth enabled", "base_url", *looseBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := router.Config{
|
||||||
|
Engine: engine,
|
||||||
|
Static: staticFS,
|
||||||
|
AllowedOrigins: splitAndTrim(*origins),
|
||||||
|
Logger: logger,
|
||||||
|
BrowserAuth: browserAuth,
|
||||||
|
}
|
||||||
|
if len(cfg.AllowedOrigins) == 0 {
|
||||||
|
cfg.AllowedOrigins = []string{router.DefaultAllowedOrigin}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, err := router.NewWithConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: *addr,
|
||||||
|
Handler: handler,
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
WriteTimeout: writeTimeout,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelWarn),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", *addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen on %s: %w", *addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("go-doc listening",
|
||||||
|
"addr", listener.Addr().String(),
|
||||||
|
"frontend_embedded", staticFS != nil,
|
||||||
|
"cors_origins", strings.Join(cfg.AllowedOrigins, ", "),
|
||||||
|
)
|
||||||
|
|
||||||
|
serveErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
serveErr <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveErr <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-serveErr:
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http server: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
stop()
|
||||||
|
logger.Info("shutdown signal received", "grace", shutdownGracePeriod.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGracePeriod)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.Error("graceful shutdown failed; forcing close", "error", err)
|
||||||
|
if closeErr := srv.Close(); closeErr != nil {
|
||||||
|
return fmt.Errorf("force close: %w", closeErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-serveErr:
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http server: %w", err)
|
||||||
|
}
|
||||||
|
case <-time.After(shutdownSignalWindow):
|
||||||
|
}
|
||||||
|
logger.Info("stopped cleanly")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLogger(level, format string) *slog.Logger {
|
||||||
|
var lvl slog.Level
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case "debug":
|
||||||
|
lvl = slog.LevelDebug
|
||||||
|
case "warn", "warning":
|
||||||
|
lvl = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
lvl = slog.LevelError
|
||||||
|
default:
|
||||||
|
lvl = slog.LevelInfo
|
||||||
|
}
|
||||||
|
opts := &slog.HandlerOptions{Level: lvl}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||||
|
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||||
|
}
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stdout, opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitAndTrim(raw string) []string {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
768
backend/router/router.go
Normal file
768
backend/router/router.go
Normal file
|
|
@ -0,0 +1,768 @@
|
||||||
|
// Package router wires the go-doc REST API onto net/http.
|
||||||
|
//
|
||||||
|
// It exposes flat-file CRUD for Markdown documents, audience-badged comment
|
||||||
|
// tracking, and -- when built via `make build` -- serves the compiled React
|
||||||
|
// bundle from an embedded filesystem so the Go binary is fully self-contained.
|
||||||
|
// Routes use the standard library's method-and-wildcard patterns, which keeps
|
||||||
|
// the backend dependency-free.
|
||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-doc/auth"
|
||||||
|
"go-doc/go-loose/client"
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default payload ceilings, applied with http.MaxBytesReader before any parsing.
|
||||||
|
const (
|
||||||
|
DefaultMaxDocBytes = 5 << 20 // 5 MiB of Markdown
|
||||||
|
DefaultMaxCommentBytes = 256 << 10 // 256 KiB per comment
|
||||||
|
DefaultAllowedOrigin = "*"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config tunes the HTTP layer. Zero values fall back to the defaults above.
|
||||||
|
type Config struct {
|
||||||
|
Engine *storage.Engine
|
||||||
|
Static fs.FS
|
||||||
|
AllowedOrigins []string
|
||||||
|
MaxDocBytes int64
|
||||||
|
MaxCommentByte int64
|
||||||
|
Logger *slog.Logger
|
||||||
|
// DevFallback is returned when Static is nil so a developer hitting the API
|
||||||
|
// port directly gets an actionable hint instead of a bare 404.
|
||||||
|
DevFallback string
|
||||||
|
// BrowserAuth enables go-loose OAuth authentication for the web UI.
|
||||||
|
BrowserAuth *client.BrowserAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) withDefaults() Config {
|
||||||
|
if c.MaxDocBytes <= 0 {
|
||||||
|
c.MaxDocBytes = DefaultMaxDocBytes
|
||||||
|
}
|
||||||
|
if c.MaxCommentByte <= 0 {
|
||||||
|
c.MaxCommentByte = DefaultMaxCommentBytes
|
||||||
|
}
|
||||||
|
if len(c.AllowedOrigins) == 0 {
|
||||||
|
c.AllowedOrigins = []string{DefaultAllowedOrigin}
|
||||||
|
}
|
||||||
|
if c.Logger == nil {
|
||||||
|
c.Logger = slog.Default()
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the fully decorated handler around a storage engine.
|
||||||
|
func New(engine *storage.Engine) (http.Handler, error) {
|
||||||
|
return NewWithConfig(Config{Engine: engine})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithConfig builds the handler stack from an explicit configuration.
|
||||||
|
func NewWithConfig(cfg Config) (http.Handler, error) {
|
||||||
|
cfg = cfg.withDefaults()
|
||||||
|
if cfg.Engine == nil {
|
||||||
|
engine, err := storage.Default()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("router: resolve storage engine: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Engine = engine
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &handler{engine: cfg.Engine, cfg: cfg}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /api/health", h.health)
|
||||||
|
mux.HandleFunc("GET /api/roles", h.roles)
|
||||||
|
mux.HandleFunc("GET /api/docs", h.listDocs)
|
||||||
|
mux.HandleFunc("GET /api/docs/{slug}", h.getDoc)
|
||||||
|
mux.HandleFunc("HEAD /api/docs/{slug}", h.getDoc)
|
||||||
|
mux.HandleFunc("POST /api/docs/{slug}", h.saveDoc)
|
||||||
|
mux.HandleFunc("PUT /api/docs/{slug}", h.saveDoc)
|
||||||
|
mux.HandleFunc("DELETE /api/docs/{slug}", h.deleteDoc)
|
||||||
|
mux.HandleFunc("GET /api/docs/{slug}/comments", h.listComments)
|
||||||
|
mux.HandleFunc("POST /api/docs/{slug}/comments", h.createComment)
|
||||||
|
mux.HandleFunc("DELETE /api/docs/{slug}/comments/{id}", h.deleteComment)
|
||||||
|
mux.HandleFunc("GET /api/docs/{slug}/stats", h.docStats)
|
||||||
|
mux.HandleFunc("GET /api/stats", h.globalStats)
|
||||||
|
|
||||||
|
// Operations endpoints
|
||||||
|
mux.HandleFunc("GET /api/ops/scripts", h.downloadScripts)
|
||||||
|
mux.HandleFunc("GET /api/ops/docs", h.getOpsDoc)
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
mux.HandleFunc("GET /auth/login", h.login)
|
||||||
|
mux.HandleFunc("GET /auth/callback", h.callback)
|
||||||
|
mux.HandleFunc("POST /auth/logout", h.logout)
|
||||||
|
mux.HandleFunc("GET /api/auth/profile", h.profile)
|
||||||
|
|
||||||
|
mux.Handle("/", auth.BrowserAuthMiddleware(cfg.BrowserAuth)(h.frontend()))
|
||||||
|
|
||||||
|
var handler http.Handler = mux
|
||||||
|
handler = corsMiddleware(cfg.AllowedOrigins)(handler)
|
||||||
|
handler = loggingMiddleware(cfg.Logger)(handler)
|
||||||
|
handler = recoverMiddleware(cfg.Logger)(handler)
|
||||||
|
return handler, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
engine *storage.Engine
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(payload); err != nil {
|
||||||
|
slog.Error("router: encode response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorBody struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
} `json:"error"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string) {
|
||||||
|
var body errorBody
|
||||||
|
body.Error.Code = code
|
||||||
|
body.Error.Message = message
|
||||||
|
body.Error.RequestID = requestIDFrom(r.Context())
|
||||||
|
body.Status = status
|
||||||
|
writeJSON(w, status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeStoreError maps engine sentinels onto HTTP status codes.
|
||||||
|
func (h *handler) writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrNotFound):
|
||||||
|
writeError(w, r, http.StatusNotFound, "not_found", err.Error())
|
||||||
|
case errors.Is(err, storage.ErrInvalidSlug):
|
||||||
|
writeError(w, r, http.StatusBadRequest, "invalid_slug", err.Error())
|
||||||
|
case errors.Is(err, storage.ErrInvalidRole):
|
||||||
|
writeError(w, r, http.StatusBadRequest, "invalid_role", err.Error())
|
||||||
|
case errors.Is(err, storage.ErrInvalidComment):
|
||||||
|
writeError(w, r, http.StatusBadRequest, "invalid_comment", err.Error())
|
||||||
|
default:
|
||||||
|
h.cfg.Logger.Error("storage failure", "error", err, "path", r.URL.Path)
|
||||||
|
writeError(w, r, http.StatusInternalServerError, "internal_error", "storage operation failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API: documents
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *handler) health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "go-doc",
|
||||||
|
"data_root": h.engine.Root(),
|
||||||
|
"time": time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) roles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
roles := make([]map[string]string, 0, 3)
|
||||||
|
for _, role := range storage.Roles() {
|
||||||
|
roles = append(roles, map[string]string{
|
||||||
|
"role": string(role),
|
||||||
|
"badge": role.Badge(),
|
||||||
|
"label": role.Label(),
|
||||||
|
"hex": role.Hex(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"roles": roles})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) listDocs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
detailed := r.URL.Query().Get("counts") == "1" || r.URL.Query().Get("counts") == "true"
|
||||||
|
|
||||||
|
var (
|
||||||
|
docs []storage.DocMeta
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if detailed {
|
||||||
|
docs, err = h.engine.ListDocsDetailed()
|
||||||
|
} else {
|
||||||
|
docs, err = h.engine.Search(query)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"docs": docs,
|
||||||
|
"count": len(docs),
|
||||||
|
"query": query,
|
||||||
|
"data_root": h.engine.Root(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) getDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
content, err := h.engine.ReadDoc(slug)
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if wantsJSON(r) {
|
||||||
|
meta, metaErr := h.engine.Stat(slug)
|
||||||
|
if metaErr != nil {
|
||||||
|
h.writeStoreError(w, r, metaErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"slug": meta.Slug,
|
||||||
|
"title": meta.Title,
|
||||||
|
"content": content,
|
||||||
|
"size": meta.Size,
|
||||||
|
"modified": meta.Modified,
|
||||||
|
"comments": meta.Comments,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if _, err := io.WriteString(w, content); err != nil {
|
||||||
|
h.cfg.Logger.Error("write markdown response", "error", err, "slug", slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveRequest is the JSON body accepted by POST /api/docs/{slug}. Raw
|
||||||
|
// text/markdown bodies are accepted too, which is what the Markdown editor tab
|
||||||
|
// in the UI sends.
|
||||||
|
type saveRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) saveDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
body := http.MaxBytesReader(w, r.Body, h.cfg.MaxDocBytes)
|
||||||
|
|
||||||
|
title := strings.TrimSpace(r.URL.Query().Get("title"))
|
||||||
|
var content string
|
||||||
|
|
||||||
|
if isJSONRequest(r) {
|
||||||
|
var payload saveRequest
|
||||||
|
if err := json.NewDecoder(body).Decode(&payload); err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, "invalid_body", "expected JSON with a content field: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = payload.Content
|
||||||
|
if t := strings.TrimSpace(payload.Title); t != "" {
|
||||||
|
title = t
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw, err := io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, "body_too_large", fmt.Sprintf("document exceeds %d bytes", h.cfg.MaxDocBytes))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
existed, err := h.engine.DocExists(slug)
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := h.engine.SaveDoc(slug, content)
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := h.engine.Stat(normalized)
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
meta.Title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
status := http.StatusOK
|
||||||
|
if !existed {
|
||||||
|
status = http.StatusCreated
|
||||||
|
}
|
||||||
|
writeJSON(w, status, map[string]any{
|
||||||
|
"slug": meta.Slug,
|
||||||
|
"title": meta.Title,
|
||||||
|
"size": meta.Size,
|
||||||
|
"modified": meta.Modified,
|
||||||
|
"created": !existed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) deleteDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
if err := h.engine.DeleteDoc(slug); err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) listComments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
comments, err := h.engine.ListComments(slug)
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter comments by tenant if user is authenticated
|
||||||
|
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||||||
|
filtered := make([]storage.Comment, 0, len(comments))
|
||||||
|
for _, c := range comments {
|
||||||
|
// Admin can see all comments, otherwise only show comments from same tenant
|
||||||
|
if user.IsAdmin() || c.TenantID == user.TenantID {
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comments = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"slug": storage.Slugify(slug),
|
||||||
|
"comments": comments,
|
||||||
|
"count": len(comments),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) createComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
body := http.MaxBytesReader(w, r.Body, h.cfg.MaxCommentByte)
|
||||||
|
|
||||||
|
var input storage.CommentInput
|
||||||
|
if err := json.NewDecoder(body).Decode(&input); err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, "invalid_body", "expected JSON with author, role and content: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach tenant/user info from auth context if available
|
||||||
|
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||||||
|
input.TenantID = user.TenantID
|
||||||
|
input.TenantSlug = user.TenantSlug
|
||||||
|
input.UserID = user.ID
|
||||||
|
if input.Author == "" {
|
||||||
|
input.Author = user.DisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
comment, err := h.engine.AppendComment(slug, input)
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"comment": comment,
|
||||||
|
"badge": comment.Badge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) deleteComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
removed, err := h.engine.DeleteComment(slug, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
writeError(w, r, http.StatusNotFound, "not_found", "comment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) docStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
stats, err := h.engine.Stats(slug)
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) globalStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docs, err := h.engine.ListDocs()
|
||||||
|
if err != nil {
|
||||||
|
h.writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
byRole := map[string]int{}
|
||||||
|
totalComments := 0
|
||||||
|
for _, doc := range docs {
|
||||||
|
stats, err := h.engine.Stats(doc.Slug)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalComments += stats.Total
|
||||||
|
for role, n := range stats.ByRole {
|
||||||
|
byRole[role] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"docs": len(docs),
|
||||||
|
"comments": totalComments,
|
||||||
|
"by_role": byRole,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadScripts serves the operations scripts zip file.
|
||||||
|
func (h *handler) downloadScripts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
zipPath := path.Join(h.engine.DocsDir(), "ops", "scripts.zip")
|
||||||
|
if _, err := os.Stat(zipPath); os.IsNotExist(err) {
|
||||||
|
writeError(w, r, http.StatusNotFound, "not_found", "scripts archive not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=\"ops-scripts.zip\"")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
http.ServeFile(w, r, zipPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOpsDoc serves the operations documentation.
|
||||||
|
func (h *handler) getOpsDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docPath := path.Join(h.engine.DocsDir(), "ops", "DEPLOYMENT.md")
|
||||||
|
content, err := os.ReadFile(docPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
writeError(w, r, http.StatusNotFound, "not_found", "documentation not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to read documentation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// login redirects to go-loose for authentication.
|
||||||
|
func (h *handler) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.cfg.BrowserAuth == nil {
|
||||||
|
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.cfg.BrowserAuth.LoginHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callback handles the OAuth callback from go-loose.
|
||||||
|
func (h *handler) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.cfg.BrowserAuth == nil {
|
||||||
|
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.cfg.BrowserAuth.CallbackHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logout clears the session and redirects to go-loose logout.
|
||||||
|
func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.cfg.BrowserAuth == nil {
|
||||||
|
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.cfg.BrowserAuth.LogoutHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// profile returns the current user's profile.
|
||||||
|
func (h *handler) profile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, r, http.StatusUnauthorized, "unauthorized", "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"display_name": user.DisplayName,
|
||||||
|
"avatar_url": user.AvatarURL,
|
||||||
|
"tenant_id": user.TenantID,
|
||||||
|
"tenant_slug": user.TenantSlug,
|
||||||
|
"application_id": user.ApplicationID,
|
||||||
|
"application": user.Application,
|
||||||
|
"role": user.Role,
|
||||||
|
"permissions": user.Permissions,
|
||||||
|
"memberships": []any{}, // TODO: fetch from go-loose if needed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Frontend
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *handler) frontend() http.Handler {
|
||||||
|
if h.cfg.Static == nil {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
writeError(w, r, http.StatusNotFound, "not_found", "unknown endpoint "+r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message := h.cfg.DevFallback
|
||||||
|
if message == "" {
|
||||||
|
message = "go-doc backend is running. Start the Vite dev server with `make frontend-run`, or run `make build` to embed the production bundle."
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, "<!doctype html><meta charset=\"utf-8\"><title>go-doc API</title><body style=\"font-family:ui-monospace,monospace;background:#0b0f14;color:#cbd5e1;padding:3rem\"><h1>go-doc API</h1><p>%s</p><p>Endpoints: <code>/api/health</code>, <code>/api/docs</code>, <code>/api/docs/{slug}</code>, <code>/api/docs/{slug}/comments</code>.</p></body>", message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return spaHandler(h.cfg.Static)
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaHandler serves the embedded React bundle and falls back to index.html for
|
||||||
|
// client-side routes such as /docs/core-service-api.
|
||||||
|
func spaHandler(static fs.FS) http.Handler {
|
||||||
|
files := http.FileServerFS(static)
|
||||||
|
serveIndex := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
data, err := fs.ReadFile(static, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "frontend bundle missing: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.Method != http.MethodHead {
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
writeError(w, r, http.StatusNotFound, "not_found", "unknown endpoint "+r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||||
|
if name == "" || name == "." {
|
||||||
|
serveIndex(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !fs.ValidPath(name) {
|
||||||
|
writeError(w, r, http.StatusBadRequest, "invalid_path", "invalid request path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := static.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
serveIndex(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, statErr := f.Stat()
|
||||||
|
f.Close()
|
||||||
|
if statErr != nil || info.IsDir() {
|
||||||
|
serveIndex(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashed build artefacts are immutable; everything else must revalidate.
|
||||||
|
if strings.HasPrefix(name, "assets/") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Middleware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func corsMiddleware(allowed []string) func(http.Handler) http.Handler {
|
||||||
|
set := make(map[string]struct{}, len(allowed))
|
||||||
|
for _, o := range allowed {
|
||||||
|
if trimmed := strings.TrimSpace(o); trimmed != "" {
|
||||||
|
set[strings.ToLower(trimmed)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A literal "*" entry means "reflect any origin", which is the default
|
||||||
|
// configuration. Credentials are never allowed, so the wildcard stays safe.
|
||||||
|
_, allowAll := set["*"]
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
switch {
|
||||||
|
case origin == "":
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", DefaultAllowedOrigin)
|
||||||
|
case allowAll:
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
default:
|
||||||
|
normalized := strings.TrimSpace(strings.ToLower(origin))
|
||||||
|
if _, ok := set[normalized]; ok {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Add("Vary", "Origin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
w.Header().Set(
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Origin, Content-Type, Content-Length, Accept, Authorization, X-Requested-With, X-Request-Id",
|
||||||
|
)
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "X-Request-Id, Content-Length, Content-Type")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "600")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
bytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusRecorder) WriteHeader(code int) {
|
||||||
|
if s.status == 0 {
|
||||||
|
s.status = code
|
||||||
|
s.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusRecorder) Write(b []byte) (int, error) {
|
||||||
|
if s.status == 0 {
|
||||||
|
s.status = http.StatusOK
|
||||||
|
}
|
||||||
|
n, err := s.ResponseWriter.Write(b)
|
||||||
|
s.bytes += n
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusRecorder) Flush() {
|
||||||
|
if f, ok := s.ResponseWriter.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
rec := &statusRecorder{ResponseWriter: w}
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
|
||||||
|
status := rec.status
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if status >= 500 {
|
||||||
|
level = slog.LevelError
|
||||||
|
} else if status >= 400 {
|
||||||
|
level = slog.LevelWarn
|
||||||
|
}
|
||||||
|
logger.Log(r.Context(), level, "http",
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"status", status,
|
||||||
|
"bytes", rec.bytes,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoverMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
logger.Error("panic recovered",
|
||||||
|
"error", fmt.Sprint(rec),
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"stack", string(debug.Stack()),
|
||||||
|
)
|
||||||
|
writeError(w, r, http.StatusInternalServerError, "internal_error", "unexpected server error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Small helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func isJSONRequest(r *http.Request) bool {
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if ct == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||||
|
ct = ct[:i]
|
||||||
|
}
|
||||||
|
ct = strings.ToLower(strings.TrimSpace(ct))
|
||||||
|
return ct == "application/json" || strings.HasSuffix(ct, "+json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantsJSON(r *http.Request) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) {
|
||||||
|
case "json":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
accept := r.Header.Get("Accept")
|
||||||
|
return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/markdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestIDFrom(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestIDKey = contextKey("godoc-request-id")
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
498
backend/scripts/docgen/main.go
Normal file
498
backend/scripts/docgen/main.go
Normal file
|
|
@ -0,0 +1,498 @@
|
||||||
|
// Command docgen analyses every go-* project under a workspace root and imports
|
||||||
|
// audience-segmented documentation for each one into go-doc.
|
||||||
|
//
|
||||||
|
// One-shot sync:
|
||||||
|
//
|
||||||
|
// go run ./scripts/docgen -root /home/dnoulet/go -data ../data
|
||||||
|
//
|
||||||
|
// Continuous sync, re-importing whenever a project's docs or source change:
|
||||||
|
//
|
||||||
|
// go run ./scripts/docgen -root /home/dnoulet/go -data ../data -watch
|
||||||
|
//
|
||||||
|
// Imported pages are wrapped in generated markers. Re-running replaces only the
|
||||||
|
// machine-written region, so prose written outside the markers survives, and a
|
||||||
|
// page that was never generated is left untouched unless -force is passed.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-doc/docgen"
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
slog.Error("fatal", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
root string
|
||||||
|
dataDir string
|
||||||
|
apps string
|
||||||
|
skip string
|
||||||
|
watch bool
|
||||||
|
interval time.Duration
|
||||||
|
dryRun bool
|
||||||
|
force bool
|
||||||
|
importSubdoc bool
|
||||||
|
verbose bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
var opts options
|
||||||
|
|
||||||
|
flag.StringVar(&opts.root, "root", defaultRoot(), "directory containing the go-* projects")
|
||||||
|
flag.StringVar(&opts.dataDir, "data", storage.ResolveRoot(), "go-doc data directory to import into")
|
||||||
|
flag.StringVar(&opts.apps, "apps", "", "comma-separated project names to limit the run (default: all)")
|
||||||
|
flag.StringVar(&opts.skip, "skip", "go-doc", "comma-separated project names to ignore")
|
||||||
|
flag.BoolVar(&opts.watch, "watch", false, "keep running and re-import when a project changes")
|
||||||
|
flag.DurationVar(&opts.interval, "interval", 5*time.Second, "poll interval for -watch")
|
||||||
|
flag.BoolVar(&opts.dryRun, "dry-run", false, "analyse and render without writing anything")
|
||||||
|
flag.BoolVar(&opts.force, "force", false, "overwrite pages that carry no generated markers")
|
||||||
|
flag.BoolVar(&opts.importSubdoc, "import-subdocs", false, "also import each project's own Markdown as separate go-doc pages")
|
||||||
|
flag.BoolVar(&opts.verbose, "verbose", false, "log at debug level")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if opts.verbose {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||||
|
|
||||||
|
engine, err := storage.New(opts.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot open the go-doc data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
im := newImporter(opts, engine, logger)
|
||||||
|
|
||||||
|
if !opts.watch {
|
||||||
|
result, err := im.SyncOnce()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.report(logger)
|
||||||
|
if result.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d project(s) failed to import", result.Failed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("watching for documentation changes",
|
||||||
|
"root", opts.root,
|
||||||
|
"interval", opts.interval.String(),
|
||||||
|
"data", engine.Root(),
|
||||||
|
"hint", "Ctrl-C to stop",
|
||||||
|
)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
changed, err := im.Scan()
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
logger.Error("scan failed", "error", err)
|
||||||
|
case len(changed) == 0:
|
||||||
|
logger.Debug("no changes")
|
||||||
|
default:
|
||||||
|
logger.Info("change detected", "projects", strings.Join(sortedKeys(changed), ", "))
|
||||||
|
result, syncErr := im.Import(sortedKeys(changed))
|
||||||
|
if syncErr != nil {
|
||||||
|
logger.Error("sync failed", "error", syncErr)
|
||||||
|
}
|
||||||
|
result.report(logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
<-ticker.C
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRoot() string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("GO_DOC_WORKSPACE")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "/home/dnoulet/go"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result summarises one sync pass.
|
||||||
|
type Result struct {
|
||||||
|
Created int
|
||||||
|
Updated int
|
||||||
|
Unchanged int
|
||||||
|
Skipped int
|
||||||
|
Failed int
|
||||||
|
Subdocs int
|
||||||
|
Pages []pageResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Result) report(logger *slog.Logger) {
|
||||||
|
logger.Info("sync complete",
|
||||||
|
"created", r.Created,
|
||||||
|
"updated", r.Updated,
|
||||||
|
"unchanged", r.Unchanged,
|
||||||
|
"skipped", r.Skipped,
|
||||||
|
"subdocs", r.Subdocs,
|
||||||
|
"failed", r.Failed,
|
||||||
|
)
|
||||||
|
for _, page := range r.Pages {
|
||||||
|
attrs := []any{"project", page.Project, "slug", page.Slug, "status", string(page.Status)}
|
||||||
|
if len(page.Notes) > 0 {
|
||||||
|
attrs = append(attrs, "notes", strings.Join(page.Notes, ", "))
|
||||||
|
}
|
||||||
|
if page.Sub {
|
||||||
|
attrs = append(attrs, "subdocument", true)
|
||||||
|
}
|
||||||
|
if page.Status == docgen.StatusSkipped {
|
||||||
|
logger.Warn("skipped", attrs...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Info("imported", attrs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageResult is the outcome for one written page.
|
||||||
|
type pageResult struct {
|
||||||
|
Project string
|
||||||
|
Slug string
|
||||||
|
Status docgen.Status
|
||||||
|
Notes []string
|
||||||
|
Sub bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// importer holds the sync state shared by the one-shot and watch paths.
|
||||||
|
type importer struct {
|
||||||
|
opts options
|
||||||
|
engine *storage.Engine
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
only map[string]struct{}
|
||||||
|
skipSet map[string]struct{}
|
||||||
|
|
||||||
|
// seen maps a project name to the signature observed on the previous poll,
|
||||||
|
// which is what makes "did this change?" a cheap map lookup.
|
||||||
|
seen map[string]string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newImporter(opts options, engine *storage.Engine, logger *slog.Logger) *importer {
|
||||||
|
return &importer{
|
||||||
|
opts: opts,
|
||||||
|
engine: engine,
|
||||||
|
logger: logger,
|
||||||
|
only: splitSet(opts.apps),
|
||||||
|
skipSet: splitSet(opts.skip),
|
||||||
|
seen: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSet(raw string) map[string]struct{} {
|
||||||
|
out := map[string]struct{}{}
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||||
|
out[trimmed] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover returns the project names selected by -apps and -skip.
|
||||||
|
func (im *importer) Discover() ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(im.opts.root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read workspace root %s: %w", im.opts.root, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if !entry.IsDir() || !strings.HasPrefix(name, "go-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, skip := im.skipSet[name]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(im.only) > 0 {
|
||||||
|
if _, wanted := im.only[name]; !wanted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signatures returns the current source signature per project.
|
||||||
|
func (im *importer) Signatures() (map[string]string, error) {
|
||||||
|
names, err := im.Discover()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
project, err := docgen.Analyze(name, filepath.Join(im.opts.root, name))
|
||||||
|
if err != nil {
|
||||||
|
im.logger.Debug("cannot analyse project", "project", name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[name] = project.Signature
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncOnce analyses and imports every discovered project.
|
||||||
|
func (im *importer) SyncOnce() (Result, error) {
|
||||||
|
names, err := im.Discover()
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
im.logger.Warn("no go-* projects discovered", "root", im.opts.root)
|
||||||
|
}
|
||||||
|
return im.Import(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan returns the projects whose signature changed since the previous poll,
|
||||||
|
// including any project seen for the first time. Remembering every signature —
|
||||||
|
// not just the changed ones — keeps a project from re-importing forever once it
|
||||||
|
// has been synced. A project that has left the workspace is forgotten and
|
||||||
|
// reported: its page stays on disk untouched, because a sync that silently
|
||||||
|
// deletes documentation is worse than one that leaves it stale.
|
||||||
|
func (im *importer) Scan() (map[string]string, error) {
|
||||||
|
sigs, err := im.Signatures()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
im.mu.Lock()
|
||||||
|
defer im.mu.Unlock()
|
||||||
|
|
||||||
|
changed := map[string]string{}
|
||||||
|
for name, sig := range sigs {
|
||||||
|
if previous, seen := im.seen[name]; !seen || previous != sig {
|
||||||
|
changed[name] = sig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, sig := range sigs {
|
||||||
|
im.seen[name] = sig
|
||||||
|
}
|
||||||
|
|
||||||
|
var departed []string
|
||||||
|
for name := range im.seen {
|
||||||
|
if _, present := sigs[name]; !present {
|
||||||
|
departed = append(departed, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(departed)
|
||||||
|
for _, name := range departed {
|
||||||
|
delete(im.seen, name)
|
||||||
|
im.logger.Info("project left the workspace; its page was left as is",
|
||||||
|
"project", name,
|
||||||
|
"hint", "delete the page by hand if the project is gone for good",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import analyses each named project and writes its page through the storage
|
||||||
|
// engine, so the result is slugified, atomically written and immediately
|
||||||
|
// visible to the API and the frontend.
|
||||||
|
func (im *importer) Import(names []string) (Result, error) {
|
||||||
|
var (
|
||||||
|
result Result
|
||||||
|
mu sync.Mutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(name string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
pages, subdocs, err := im.importOne(name)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
result.Failed++
|
||||||
|
im.logger.Error("import failed", "project", name, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.Subdocs += subdocs
|
||||||
|
result.Pages = append(result.Pages, pages...)
|
||||||
|
for _, page := range pages {
|
||||||
|
if page.Sub {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch page.Status {
|
||||||
|
case docgen.StatusCreated:
|
||||||
|
result.Created++
|
||||||
|
case docgen.StatusUpdated:
|
||||||
|
result.Updated++
|
||||||
|
case docgen.StatusUnchanged:
|
||||||
|
result.Unchanged++
|
||||||
|
case docgen.StatusSkipped:
|
||||||
|
result.Skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(name)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
sort.Slice(result.Pages, func(i, j int) bool { return result.Pages[i].Slug < result.Pages[j].Slug })
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// importOne builds and writes the page for a single project, plus any sub-pages
|
||||||
|
// when -import-subdocs is set.
|
||||||
|
func (im *importer) importOne(name string) ([]pageResult, int, error) {
|
||||||
|
root := filepath.Join(im.opts.root, name)
|
||||||
|
|
||||||
|
project, err := docgen.Analyze(name, root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
page, err := docgen.Render(project)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := storage.Slugify(name)
|
||||||
|
|
||||||
|
status, err := im.write(slug, page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []pageResult{{
|
||||||
|
Project: name,
|
||||||
|
Slug: slug,
|
||||||
|
Status: status,
|
||||||
|
Notes: notes(project),
|
||||||
|
}}
|
||||||
|
|
||||||
|
if !im.opts.importSubdoc {
|
||||||
|
return out, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subpages := im.subdocPages(project)
|
||||||
|
for _, sub := range subpages {
|
||||||
|
subStatus, err := im.write(sub.slug, sub.body)
|
||||||
|
if err != nil {
|
||||||
|
im.logger.Warn("subdocument import failed",
|
||||||
|
"project", name, "path", sub.source, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, pageResult{
|
||||||
|
Project: name,
|
||||||
|
Slug: sub.slug,
|
||||||
|
Status: subStatus,
|
||||||
|
Sub: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, len(subpages), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type subdocPage struct {
|
||||||
|
source string
|
||||||
|
slug string
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// subdocPages turns each Markdown file the project already owns into its own
|
||||||
|
// go-doc page, wrapped so a later sync refreshes it instead of orphaning it.
|
||||||
|
func (im *importer) subdocPages(project *docgen.Project) []subdocPage {
|
||||||
|
base := storage.Slugify(project.Name)
|
||||||
|
var out []subdocPage
|
||||||
|
|
||||||
|
for _, doc := range project.Docs {
|
||||||
|
if doc.Path == project.Readme {
|
||||||
|
continue // already the summary of the main page
|
||||||
|
}
|
||||||
|
stem := strings.TrimSuffix(filepath.Base(doc.Path), filepath.Ext(doc.Path))
|
||||||
|
slug := storage.Slugify(project.Name + "-" + stem)
|
||||||
|
if slug == "" || slug == base {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(project.Path, filepath.FromSlash(doc.Path)))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
"Imported from %s in the %s repository.\n\n---\n\n%s",
|
||||||
|
doc.Path, project.Name, strings.TrimRight(string(data), "\n"),
|
||||||
|
)
|
||||||
|
out = append(out, subdocPage{
|
||||||
|
source: doc.Path,
|
||||||
|
slug: slug,
|
||||||
|
body: docgen.Wrap(project.Name+":"+doc.Path, project.Signature, body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// write merges a generated page with whatever is already on disk. Existing prose
|
||||||
|
// outside the markers survives; a page with no markers is skipped unless force.
|
||||||
|
func (im *importer) write(slug, page string) (docgen.Status, error) {
|
||||||
|
var existing string
|
||||||
|
if data, err := os.ReadFile(filepath.Join(im.engine.DocsDir(), slug+".md")); err == nil {
|
||||||
|
existing = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, status, err := docgen.Merge(existing, page, im.opts.force)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if im.opts.dryRun {
|
||||||
|
im.logger.Debug("dry run", "slug", slug, "status", string(status), "bytes", len(merged))
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
if _, err := im.engine.SaveDoc(slug, merged); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notes surfaces anything a reader should know about a generated page.
|
||||||
|
func notes(project *docgen.Project) []string {
|
||||||
|
var out []string
|
||||||
|
if project.Readme == "" {
|
||||||
|
out = append(out, "no README found")
|
||||||
|
}
|
||||||
|
if len(project.Routes) == 0 {
|
||||||
|
out = append(out, "no HTTP routes detected")
|
||||||
|
}
|
||||||
|
if len(project.Modules) == 0 {
|
||||||
|
out = append(out, "no go.mod found")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]string) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for key := range m {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
172
backend/scripts/docgen/main_test.go
Normal file
172
backend/scripts/docgen/main_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScanDetectsNewProjectsAndEditsButSettlesWhenIdle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
writeProject(t, filepath.Join(root, "go-alpha"))
|
||||||
|
|
||||||
|
im := newImporter(options{root: root}, newEngine(t), discardLogger())
|
||||||
|
|
||||||
|
// A project nobody has seen counts as changed.
|
||||||
|
changed := scan(t, im)
|
||||||
|
if want := []string{"go-alpha"}; !equal(changed, want) {
|
||||||
|
t.Fatalf("first scan = %v, want %v", changed, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An untouched project must not re-import on every poll.
|
||||||
|
if changed := scan(t, im); len(changed) != 0 {
|
||||||
|
t.Errorf("idle scan reported %v, want no changes", changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project that appears mid-run is picked up.
|
||||||
|
writeProject(t, filepath.Join(root, "go-beta"))
|
||||||
|
if changed := scan(t, im); !equal(changed, []string{"go-beta"}) {
|
||||||
|
t.Errorf("scan after a new project = %v, want [go-beta]", changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An edit to an existing project is picked up, once.
|
||||||
|
writeProject(t, filepath.Join(root, "go-alpha"))
|
||||||
|
if changed := scan(t, im); !equal(changed, []string{"go-alpha"}) {
|
||||||
|
t.Errorf("scan after an edit = %v, want [go-alpha]", changed)
|
||||||
|
}
|
||||||
|
if changed := scan(t, im); len(changed) != 0 {
|
||||||
|
t.Errorf("the edit was reported twice: %v", changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanForgetsProjectsThatLeaveTheWorkspace(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
project := filepath.Join(root, "go-alpha")
|
||||||
|
writeProject(t, project)
|
||||||
|
|
||||||
|
var logs strings.Builder
|
||||||
|
im := newImporter(options{root: root}, newEngine(t),
|
||||||
|
slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
|
||||||
|
if changed := scan(t, im); len(changed) != 1 {
|
||||||
|
t.Fatalf("first scan = %v, want one project", changed)
|
||||||
|
}
|
||||||
|
logs.Reset()
|
||||||
|
|
||||||
|
if err := os.RemoveAll(project); err != nil {
|
||||||
|
t.Fatalf("remove project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed := scan(t, im); len(changed) != 0 {
|
||||||
|
t.Errorf("a departed project was treated as changed: %v", changed)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs.String(), "project left the workspace") {
|
||||||
|
t.Errorf("a departed project was not reported:\n%s", logs.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coming back is a change again rather than a silent no-op.
|
||||||
|
writeProject(t, project)
|
||||||
|
if changed := scan(t, im); !equal(changed, []string{"go-alpha"}) {
|
||||||
|
t.Errorf("scan after the project returned = %v, want [go-alpha]", changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverHonoursAppsAndSkip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, name := range []string{"go-alpha", "go-beta", "go-doc", "not-a-go-app"} {
|
||||||
|
writeProject(t, filepath.Join(root, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
opts options
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "default skips go-doc", opts: options{root: root, skip: "go-doc"}, want: []string{"go-alpha", "go-beta"}},
|
||||||
|
{name: "-apps narrows the run", opts: options{root: root, skip: "", apps: "go-beta"}, want: []string{"go-beta"}},
|
||||||
|
{name: "no skip at all", opts: options{root: root}, want: []string{"go-alpha", "go-beta", "go-doc"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
im := newImporter(tc.opts, newEngine(t), discardLogger())
|
||||||
|
got, err := im.Discover()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discover: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||||
|
t.Errorf("discover = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverReportsAMissingWorkspace(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
im := newImporter(options{root: filepath.Join(t.TempDir(), "absent")}, newEngine(t), discardLogger())
|
||||||
|
if _, err := im.Discover(); err == nil {
|
||||||
|
t.Fatal("a missing workspace should be reported, not silently ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scan(t *testing.T, im *importer) []string {
|
||||||
|
t.Helper()
|
||||||
|
changed, err := im.Scan()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(changed))
|
||||||
|
for name := range changed {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func equal(got, want []string) bool {
|
||||||
|
return strings.Join(got, ",") == strings.Join(want, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEngine(t *testing.T) *storage.Engine {
|
||||||
|
t.Helper()
|
||||||
|
engine, err := storage.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision engine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeProject(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "internal"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", root, err)
|
||||||
|
}
|
||||||
|
files := map[string]string{
|
||||||
|
"go.mod": "module example.com/" + filepath.Base(root) + "\n\ngo 1.27.1\n",
|
||||||
|
"README.md": "# " + filepath.Base(root) + "\n\nIt serves an HTTP API.\n",
|
||||||
|
"internal/app.go": "package internal\n\n// Run starts the app.\nfunc Run() error { return nil }\n",
|
||||||
|
"internal/app_t.go": "placeholder\n",
|
||||||
|
}
|
||||||
|
for name, content := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(name)), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
300
backend/scripts/seed_perf_data.go
Normal file
300
backend/scripts/seed_perf_data.go
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
// Command seed_perf_data floods the go-doc flat-file store with generated
|
||||||
|
// documentation and feedback so the platform can be exercised under realistic
|
||||||
|
// load.
|
||||||
|
//
|
||||||
|
// go run ./scripts/seed_perf_data.go -data ../data -docs 120 -comments 25 -workers 8
|
||||||
|
//
|
||||||
|
// Every document is a syntactically valid Markdown page carrying all three
|
||||||
|
// audience sections, and every comment is a real JSON record with a real badge.
|
||||||
|
// The generator is deterministic for a given -seed so runs are reproducible.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
dataDir = flag.String("data", storage.ResolveRoot(), "flat-file data directory")
|
||||||
|
docCount = flag.Int("docs", 120, "number of Markdown documents to generate (spec floor: 100)")
|
||||||
|
perDoc = flag.Int("comments", 25, "comments to inject per document")
|
||||||
|
workers = flag.Int("workers", 8, "parallel seeding workers")
|
||||||
|
seed = flag.Int64("seed", 20260101, "PRNG seed for reproducible output")
|
||||||
|
force = flag.Bool("force", false, "overwrite documents that already exist")
|
||||||
|
minBodyKB = flag.Int("min-body-kb", 2, "minimum document body size in KiB")
|
||||||
|
skipTemplate = flag.Bool("skip-go-doc", true, "leave data/docs/go-doc.md untouched")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
|
||||||
|
if *docCount < 100 && !*force {
|
||||||
|
logger.Warn("doc count is below the 100 document floor from the specification", "docs", *docCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine, err := storage.New(*dataDir)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("cannot open the data directory", "error", err, "data", *dataDir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("seeding flat-file store",
|
||||||
|
"data_root", engine.Root(),
|
||||||
|
"docs", *docCount,
|
||||||
|
"comments_per_doc", *perDoc,
|
||||||
|
"total_comments", *docCount**perDoc,
|
||||||
|
"workers", *workers,
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs := make(chan int, *docCount)
|
||||||
|
for i := 0; i < *docCount; i++ {
|
||||||
|
jobs <- i
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
|
||||||
|
var (
|
||||||
|
docsWritten atomic.Int64
|
||||||
|
docsSkipped atomic.Int64
|
||||||
|
commentsWritten atomic.Int64
|
||||||
|
bytesWritten atomic.Int64
|
||||||
|
wg sync.WaitGroup
|
||||||
|
failures atomic.Int64
|
||||||
|
)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
for w := 0; w < *workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(worker int) {
|
||||||
|
defer wg.Done()
|
||||||
|
// Each worker owns its own PRNG stream, which keeps output
|
||||||
|
// reproducible regardless of scheduling order.
|
||||||
|
rng := rand.New(rand.NewSource(*seed + int64(worker)*7919))
|
||||||
|
|
||||||
|
for index := range jobs {
|
||||||
|
title := documentTitle(index)
|
||||||
|
slug := storage.Slugify(title)
|
||||||
|
|
||||||
|
if *skipTemplate && slug == "go-doc" {
|
||||||
|
docsSkipped.Add(1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !*force {
|
||||||
|
exists, err := engine.DocExists(slug)
|
||||||
|
if err != nil {
|
||||||
|
failures.Add(1)
|
||||||
|
logger.Error("existence check failed", "slug", slug, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
docsSkipped.Add(1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := documentBody(title, slug, *minBodyKB, rng)
|
||||||
|
if _, err := engine.SaveDoc(slug, body); err != nil {
|
||||||
|
failures.Add(1)
|
||||||
|
logger.Error("document write failed", "slug", slug, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
docsWritten.Add(1)
|
||||||
|
bytesWritten.Add(int64(len(body)))
|
||||||
|
|
||||||
|
for n := 0; n < *perDoc; n++ {
|
||||||
|
role := storage.Roles()[rng.Intn(len(storage.Roles()))]
|
||||||
|
comment := storage.CommentInput{
|
||||||
|
Author: authorName(rng, role),
|
||||||
|
Role: string(role),
|
||||||
|
Content: commentBody(role, slug, n, rng),
|
||||||
|
}
|
||||||
|
if _, err := engine.AppendComment(slug, comment); err != nil {
|
||||||
|
failures.Add(1)
|
||||||
|
logger.Error("comment write failed", "slug", slug, "error", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
commentsWritten.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
metas, err := engine.ListDocs()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("index read failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalComments, byRole := 0, map[string]int{}
|
||||||
|
for _, meta := range metas {
|
||||||
|
stats, err := engine.Stats(meta.Slug)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalComments += stats.Total
|
||||||
|
for role, n := range stats.ByRole {
|
||||||
|
byRole[role] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := 0.0
|
||||||
|
if elapsed > 0 {
|
||||||
|
rate = float64(commentsWritten.Load()) / elapsed.Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("seed complete",
|
||||||
|
"elapsed", elapsed.Round(time.Millisecond),
|
||||||
|
"documents_written", docsWritten.Load(),
|
||||||
|
"documents_skipped", docsSkipped.Load(),
|
||||||
|
"documents_indexed", len(metas),
|
||||||
|
"comments_written", commentsWritten.Load(),
|
||||||
|
"comments_indexed", totalComments,
|
||||||
|
"markdown_bytes", bytesWritten.Load(),
|
||||||
|
"comments_per_second", int(rate),
|
||||||
|
"by_role", byRole,
|
||||||
|
)
|
||||||
|
|
||||||
|
if failures.Load() > 0 {
|
||||||
|
logger.Error("seed completed with failures", "failures", failures.Load())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nSeeded %d documents and %d comments into %s\n",
|
||||||
|
docsWritten.Load(), commentsWritten.Load(), filepath.Clean(engine.Root()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var serviceNouns = []string{
|
||||||
|
"Gateway", "Pipeline", "Registry", "Collector", "Scheduler", "Router",
|
||||||
|
"Indexer", "Notifier", "Bridge", "Vault", "Ledger", "Beacon",
|
||||||
|
"Annex", "Relay", "Forge", "Harbor", "Signal", "Archive",
|
||||||
|
"Compass", "Foundry", "Sentinel", "Orchid", "Quartz", "Meridian",
|
||||||
|
}
|
||||||
|
|
||||||
|
var serviceScopes = []string{
|
||||||
|
"Billing", "Identity", "Storage", "Messaging", "Search", "Payments",
|
||||||
|
"Analytics", "Provisioning", "Fulfilment", "Telemetry", "Consent", "Routing",
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentTitle builds a stable, human title for the nth generated document.
|
||||||
|
func documentTitle(index int) string {
|
||||||
|
scope := serviceScopes[index%len(serviceScopes)]
|
||||||
|
noun := serviceNouns[(index/len(serviceScopes))%len(serviceNouns)]
|
||||||
|
return fmt.Sprintf("%s %s Service %03d", scope, noun, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentBody(title, slug string, minBodyKB int, rng *rand.Rand) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "# %s\n\n", title)
|
||||||
|
fmt.Fprintf(&b, "> Generated performance fixture for `%s`. Regenerate with `make seed`.\n\n", slug)
|
||||||
|
b.WriteString("This page isolates the same system across three audiences. Only the section that\n")
|
||||||
|
b.WriteString("matches the selected tab is rendered in the reader.\n\n")
|
||||||
|
|
||||||
|
b.WriteString("## For Users\n\n")
|
||||||
|
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
|
||||||
|
"The guided flow walks a new user from an empty workspace to a first successful run.",
|
||||||
|
"Every destructive action asks for confirmation and explains what is about to change.",
|
||||||
|
"Keyboard shortcuts mirror the visual toolbar so the workflow stays fast.",
|
||||||
|
})
|
||||||
|
b.WriteString("\n### Common tasks\n\n")
|
||||||
|
for _, task := range []string{"Create your first workspace", "Invite a teammate", "Export a report", "Reset your password"} {
|
||||||
|
fmt.Fprintf(&b, "- %s\n", task)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
b.WriteString("## For Operators\n\n")
|
||||||
|
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
|
||||||
|
"The deployment is idempotent: re-running the pipeline converges on the declared state.",
|
||||||
|
"Health checks probe the read path before the write path is allowed to serve traffic.",
|
||||||
|
"Rollback restores the previous artefact and replays the migration in reverse.",
|
||||||
|
})
|
||||||
|
b.WriteString("\n### Runbook\n\n")
|
||||||
|
b.WriteString("```bash\n# verify the release is healthy\ncurl -fsS https://localhost:8080/api/health\n\n# roll back to the previous artefact\nmake rollback RELEASE=previous\n```\n\n")
|
||||||
|
b.WriteString("| Signal | Threshold | Action |\n| --- | --- | --- |\n| p99 latency | 250 ms | Page the on-call |\n| error rate | 1 % | Halt the rollout |\n| queue depth | 1000 | Scale workers |\n\n")
|
||||||
|
|
||||||
|
b.WriteString("## For Developers\n\n")
|
||||||
|
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
|
||||||
|
"The API is versioned in the path; a breaking change ships under a new major prefix.",
|
||||||
|
"Every write is atomic: readers observe either the previous file or the new one, never a partial write.",
|
||||||
|
"The engine slugifies titles into lower-case, hyphen-separated identifiers.",
|
||||||
|
})
|
||||||
|
b.WriteString("\n### Endpoints\n\n")
|
||||||
|
b.WriteString("```http\nGET /api/docs\nGET /api/docs/{slug}\nPOST /api/docs/{slug}\nDELETE /api/docs/{slug}\nGET /api/docs/{slug}/comments\nPOST /api/docs/{slug}/comments\n```\n\n")
|
||||||
|
|
||||||
|
b.WriteString("### Integration notes\n\n")
|
||||||
|
fmt.Fprintf(&b, "Inline references such as `data/docs/%s.md` and **bold emphasis** must survive a round trip through the editor.\n", slug)
|
||||||
|
|
||||||
|
// Top up the body so every fixture is at least minBodyKB.
|
||||||
|
for b.Len() < minBodyKB*1024 {
|
||||||
|
b.WriteString("\nAdditional reference material for load testing. ")
|
||||||
|
fmt.Fprintf(&b, "Section %d of the generated body for `%s`. ", rng.Intn(1<<20), slug)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeParagraphs(b *strings.Builder, rng *rand.Rand, count, targetLen int, seeds []string) {
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
seed := seeds[i%len(seeds)]
|
||||||
|
fmt.Fprintf(b, "%s ", seed)
|
||||||
|
for len(b.String()) < targetLen*1024 && i == count-1 {
|
||||||
|
fmt.Fprintf(b, "Reference sentence %d covering the behaviour of `%s`. ", rng.Intn(1<<20), "service")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var givenNames = []string{"ana", "bo", "chi", "dee", "eli", "fen", "gita", "hugo", "iris", "jo", "kit", "lena", "mo", "nia", "omar", "pia"}
|
||||||
|
|
||||||
|
func authorName(rng *rand.Rand, role storage.Role) string {
|
||||||
|
name := givenNames[rng.Intn(len(givenNames))]
|
||||||
|
suffix := map[storage.Role]string{
|
||||||
|
storage.RoleUser: " (user)",
|
||||||
|
storage.RoleOperator: " (ops)",
|
||||||
|
storage.RoleDeveloper: " (dev)",
|
||||||
|
}[role]
|
||||||
|
return name + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
var commentSeeds = map[storage.Role][]string{
|
||||||
|
storage.RoleUser: {
|
||||||
|
"The guided setup was easier to follow than the older docs.",
|
||||||
|
"I could not find the export button on the first screen.",
|
||||||
|
"Step 3 assumes a workspace that most new accounts do not have.",
|
||||||
|
"The keyboard shortcut list is genuinely useful.",
|
||||||
|
"Copy in the empty state does not match what actually happens.",
|
||||||
|
},
|
||||||
|
storage.RoleOperator: {
|
||||||
|
"Rollback took 20 minutes; the runbook should call that out.",
|
||||||
|
"Health checks pass while the write path is still degraded.",
|
||||||
|
"We need the p99 latency threshold surfaced on the dashboard.",
|
||||||
|
"The idempotent redeploy saved us during last week's incident.",
|
||||||
|
"Queue depth alerting fires too late to be actionable.",
|
||||||
|
},
|
||||||
|
storage.RoleDeveloper: {
|
||||||
|
"The retry budget is undocumented; please add the numbers.",
|
||||||
|
"A `--dry-run` flag would make this safe to run in CI.",
|
||||||
|
"The response schema changed without a version bump.",
|
||||||
|
"Slug collisions are possible when titles differ only by punctuation.",
|
||||||
|
"Atomic writes look correct, but the rename is not fsynced in CI images.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentBody(role storage.Role, slug string, index int, rng *rand.Rand) string {
|
||||||
|
seeds := commentSeeds[role]
|
||||||
|
base := seeds[rng.Intn(len(seeds))]
|
||||||
|
return fmt.Sprintf("%s (finding %d on %s)", base, index+1, slug)
|
||||||
|
}
|
||||||
381
backend/storage/comments.go
Normal file
381
backend/storage/comments.go
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role identifies which audience segment a piece of feedback belongs to.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
// The three audience segments isolated by go-doc.
|
||||||
|
const (
|
||||||
|
RoleUser Role = "user"
|
||||||
|
RoleOperator Role = "operator"
|
||||||
|
RoleDeveloper Role = "developer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Badge glyphs and display labels per role. Badges are derived server-side from
|
||||||
|
// the role so a client can never spoof the visual marker of another audience.
|
||||||
|
var roleMeta = map[Role]struct {
|
||||||
|
Badge string
|
||||||
|
Label string
|
||||||
|
Hex string
|
||||||
|
}{
|
||||||
|
RoleUser: {Badge: "🟢", Label: "User", Hex: "#22c55e"},
|
||||||
|
RoleOperator: {Badge: "🔵", Label: "Operator", Hex: "#3b82f6"},
|
||||||
|
RoleDeveloper: {Badge: "🔴", Label: "Developer", Hex: "#ef4444"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge returns the coloured badge glyph for the role (User 🟢, Operator 🔵,
|
||||||
|
// Developer 🔴). Unknown roles yield an empty badge.
|
||||||
|
func (r Role) Badge() string { return roleMeta[r].Badge }
|
||||||
|
|
||||||
|
// Label returns the human name of the role: User, Operator or Developer.
|
||||||
|
func (r Role) Label() string { return roleMeta[r].Label }
|
||||||
|
|
||||||
|
// Hex returns the badge colour as a CSS hex value for the frontend to mirror.
|
||||||
|
func (r Role) Hex() string { return roleMeta[r].Hex }
|
||||||
|
|
||||||
|
// Valid reports whether r is one of the three known roles.
|
||||||
|
func (r Role) Valid() bool {
|
||||||
|
_, ok := roleMeta[r]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRole canonicalises user input ("Developer", "DEV", " developer ") into a
|
||||||
|
// Role. It returns ErrInvalidRole for anything unrecognised.
|
||||||
|
func ParseRole(raw string) (Role, error) {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
switch normalized {
|
||||||
|
case string(RoleUser):
|
||||||
|
return RoleUser, nil
|
||||||
|
case string(RoleOperator):
|
||||||
|
return RoleOperator, nil
|
||||||
|
case string(RoleDeveloper):
|
||||||
|
return RoleDeveloper, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("%w: %q (want user, operator or developer)", ErrInvalidRole, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles lists every supported role in audience order.
|
||||||
|
func Roles() []Role { return []Role{RoleUser, RoleOperator, RoleDeveloper} }
|
||||||
|
|
||||||
|
// Comment is one audience message attached to a document.
|
||||||
|
type Comment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Badge string `json:"badge"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
TenantID string `json:"tenant_id,omitempty"`
|
||||||
|
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentInput is the write-side payload for an incoming comment. Badge and
|
||||||
|
// Timestamp are optional; the engine fills both in when they are absent.
|
||||||
|
type CommentInput struct {
|
||||||
|
Author string `json:"author"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||||
|
TenantID string `json:"tenant_id,omitempty"`
|
||||||
|
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxAuthorLen = 120
|
||||||
|
maxCommentLen = 8 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// commentsSuffix keeps feedback for slug "core-service-api" in
|
||||||
|
// core-service-api-comments.json, as mandated by the storage layout.
|
||||||
|
const commentsSuffix = "-comments.json"
|
||||||
|
|
||||||
|
func (e *Engine) commentPath(slug string) string {
|
||||||
|
return filepath.Join(e.commentsDir, slug+commentsSuffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeBody canonicalises line endings in a comment body without appending a
|
||||||
|
// trailing newline, so consumers can render it verbatim.
|
||||||
|
func normalizeBody(content string) string {
|
||||||
|
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||||
|
return strings.Trim(normalized, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendComment validates and stores a new comment for slug, returning the
|
||||||
|
// stored record with its generated ID, badge, and timestamp. The read-modify-write
|
||||||
|
// cycle is serialised per slug and committed atomically, so concurrent writers
|
||||||
|
// cannot lose or interleave entries.
|
||||||
|
func (e *Engine) AppendComment(slug string, in CommentInput) (Comment, error) {
|
||||||
|
normalized, err := NormalizeSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return Comment{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := ParseRole(in.Role)
|
||||||
|
if err != nil {
|
||||||
|
return Comment{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
author := strings.TrimSpace(in.Author)
|
||||||
|
if author == "" {
|
||||||
|
author = role.Label()
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(author) > maxAuthorLen {
|
||||||
|
return Comment{}, fmt.Errorf("%w: author exceeds %d characters", ErrInvalidComment, maxAuthorLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(in.Content)
|
||||||
|
if content == "" {
|
||||||
|
return Comment{}, fmt.Errorf("%w: content is empty", ErrInvalidComment)
|
||||||
|
}
|
||||||
|
content = normalizeBody(content)
|
||||||
|
if len(content) > maxCommentLen {
|
||||||
|
return Comment{}, fmt.Errorf("%w: content exceeds %d bytes", ErrInvalidComment, maxCommentLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := Comment{
|
||||||
|
ID: NewCommentID(),
|
||||||
|
Author: author,
|
||||||
|
Role: role,
|
||||||
|
Badge: role.Badge(),
|
||||||
|
Content: content,
|
||||||
|
Timestamp: in.Timestamp.UTC(),
|
||||||
|
TenantID: in.TenantID,
|
||||||
|
TenantSlug: in.TenantSlug,
|
||||||
|
UserID: in.UserID,
|
||||||
|
}
|
||||||
|
if in.Timestamp.IsZero() {
|
||||||
|
comment.Timestamp = e.now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
mu := e.lockFor(normalized)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
existing, err := e.readComments(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return Comment{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = append(existing, comment)
|
||||||
|
if err := e.writeComments(normalized, existing); err != nil {
|
||||||
|
return Comment{}, err
|
||||||
|
}
|
||||||
|
return comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListComments returns every comment for slug in insertion order, oldest first.
|
||||||
|
// A document without feedback yields an empty slice and no error.
|
||||||
|
func (e *Engine) ListComments(slug string) ([]Comment, error) {
|
||||||
|
normalized, err := NormalizeSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mu := e.lockFor(normalized)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
return e.readComments(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComment removes a single comment by ID and reports whether it existed.
|
||||||
|
func (e *Engine) DeleteComment(slug, id string) (bool, error) {
|
||||||
|
normalized, err := NormalizeSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return false, fmt.Errorf("%w: comment id is empty", ErrInvalidComment)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu := e.lockFor(normalized)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
existing, err := e.readComments(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := make([]Comment, 0, len(existing))
|
||||||
|
found := false
|
||||||
|
for _, c := range existing {
|
||||||
|
if !found && c.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remaining = append(remaining, c)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := e.writeComments(normalized, remaining); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllComments drops every comment registered for slug.
|
||||||
|
func (e *Engine) DeleteAllComments(slug string) error {
|
||||||
|
normalized, err := NormalizeSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mu := e.lockFor(normalized)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if err := os.Remove(e.commentPath(normalized)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("storage: delete comments for %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentStats summarises the feedback load on a single document.
|
||||||
|
type CommentStats struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
ByRole map[string]int `json:"by_role"`
|
||||||
|
LatestAt *time.Time `json:"latest_at,omitempty"`
|
||||||
|
OldestAt *time.Time `json:"oldest_at,omitempty"`
|
||||||
|
MostActive string `json:"most_active,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats aggregates a document's comments by role.
|
||||||
|
func (e *Engine) Stats(slug string) (CommentStats, error) {
|
||||||
|
comments, err := e.ListComments(slug)
|
||||||
|
if err != nil {
|
||||||
|
return CommentStats{}, err
|
||||||
|
}
|
||||||
|
stats := CommentStats{Total: len(comments), ByRole: map[string]int{}}
|
||||||
|
if len(comments) == 0 {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest, newest := comments[0].Timestamp, comments[0].Timestamp
|
||||||
|
for _, c := range comments {
|
||||||
|
stats.ByRole[string(c.Role)]++
|
||||||
|
if c.Timestamp.Before(oldest) {
|
||||||
|
oldest = c.Timestamp
|
||||||
|
}
|
||||||
|
if c.Timestamp.After(newest) {
|
||||||
|
newest = c.Timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.OldestAt = &oldest
|
||||||
|
stats.LatestAt = &newest
|
||||||
|
|
||||||
|
best, bestCount := "", -1
|
||||||
|
for _, role := range Roles() {
|
||||||
|
if n := stats.ByRole[string(role)]; n > bestCount {
|
||||||
|
best, bestCount = string(role), n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestCount > 0 {
|
||||||
|
stats.MostActive = best
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readComments loads the JSON array backing a slug. Callers must hold the slug
|
||||||
|
// lock. A missing or empty file yields an empty slice.
|
||||||
|
func (e *Engine) readComments(slug string) ([]Comment, error) {
|
||||||
|
path := e.commentPath(slug)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return []Comment{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("storage: read comments for %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
if len(strings.TrimSpace(string(data))) == 0 {
|
||||||
|
return []Comment{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var comments []Comment
|
||||||
|
if err := json.Unmarshal(data, &comments); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: decode comments for %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
for i := range comments {
|
||||||
|
if comments[i].Badge == "" {
|
||||||
|
comments[i].Badge = comments[i].Role.Badge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return comments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeComments serialises comments back as a JSON array. Callers must hold the
|
||||||
|
// slug lock. Entries are sorted by timestamp so the file order matches the
|
||||||
|
// reader's view even when the seeding script injects backdated records.
|
||||||
|
func (e *Engine) writeComments(slug string, comments []Comment) error {
|
||||||
|
if comments == nil {
|
||||||
|
comments = []Comment{}
|
||||||
|
}
|
||||||
|
sorted := make([]Comment, len(comments))
|
||||||
|
copy(sorted, comments)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
if sorted[i].Timestamp.Equal(sorted[j].Timestamp) {
|
||||||
|
return sorted[i].ID < sorted[j].ID
|
||||||
|
}
|
||||||
|
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
encoded, err := json.MarshalIndent(sorted, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage: encode comments for %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
|
||||||
|
if err := writeFileAtomic(e.commentPath(slug), encoded, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommentID returns a collision-resistant identifier: a millisecond timestamp
|
||||||
|
// prefix for rough chronological ordering plus 8 random bytes.
|
||||||
|
func NewCommentID() string {
|
||||||
|
var buf [8]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
// crypto/rand cannot fail on Linux; fall back to a time-derived value so
|
||||||
|
// the API degrades rather than errors.
|
||||||
|
return fmt.Sprintf("c%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("c%x_%s", time.Now().UnixMilli(), hex.EncodeToString(buf[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendComment stores feedback through the process-wide engine.
|
||||||
|
func AppendComment(slug string, in CommentInput) (Comment, error) {
|
||||||
|
e, err := Default()
|
||||||
|
if err != nil {
|
||||||
|
return Comment{}, err
|
||||||
|
}
|
||||||
|
return e.AppendComment(slug, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListComments reads feedback through the process-wide engine.
|
||||||
|
func ListComments(slug string) ([]Comment, error) {
|
||||||
|
e, err := Default()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e.ListComments(slug)
|
||||||
|
}
|
||||||
635
backend/storage/engine.go
Normal file
635
backend/storage/engine.go
Normal file
|
|
@ -0,0 +1,635 @@
|
||||||
|
// Package storage implements the flat-file persistence engine behind go-doc.
|
||||||
|
//
|
||||||
|
// Documents live on disk as raw Markdown files at data/docs/<slug>.md and
|
||||||
|
// audience feedback is stored as JSON arrays at data/comments/<slug>-comments.json.
|
||||||
|
//
|
||||||
|
// Every mutation is written with a temp-file + fsync + rename sequence so a
|
||||||
|
// concurrent reader can never observe a half-written file, and read-modify-write
|
||||||
|
// cycles on the same document are serialised by a keyed mutex. That combination
|
||||||
|
// is what lets the engine stay correct while the seed script floods it with
|
||||||
|
// thousands of concurrent comments.
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DocExt is the file extension used for every Markdown document.
|
||||||
|
DocExt = ".md"
|
||||||
|
|
||||||
|
// maxSlugLen caps generated slugs so a pathological title cannot produce an
|
||||||
|
// unusable filename.
|
||||||
|
maxSlugLen = 120
|
||||||
|
|
||||||
|
// maxTitleScanBytes limits how much of a document is scanned when extracting
|
||||||
|
// its title from the leading heading.
|
||||||
|
maxTitleScanBytes = 64 << 10
|
||||||
|
|
||||||
|
docsDirName = "docs"
|
||||||
|
commentsDirName = "comments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors returned by the engine. Callers map these onto HTTP status codes.
|
||||||
|
var (
|
||||||
|
// ErrNotFound is returned when a document or comment does not exist.
|
||||||
|
ErrNotFound = errors.New("storage: not found")
|
||||||
|
// ErrInvalidSlug is returned for slugs that cannot be safely slugified.
|
||||||
|
ErrInvalidSlug = errors.New("storage: invalid slug")
|
||||||
|
// ErrInvalidRole is returned when a comment carries an unknown role.
|
||||||
|
ErrInvalidRole = errors.New("storage: invalid role")
|
||||||
|
// ErrInvalidComment is returned when a comment payload is unusable.
|
||||||
|
ErrInvalidComment = errors.New("storage: invalid comment")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine owns a data directory and all flat-file operations against it.
|
||||||
|
// Engine is safe for concurrent use by multiple goroutines.
|
||||||
|
type Engine struct {
|
||||||
|
root string
|
||||||
|
docsDir string
|
||||||
|
commentsDir string
|
||||||
|
|
||||||
|
locksMu sync.Mutex
|
||||||
|
locks map[string]*sync.Mutex
|
||||||
|
|
||||||
|
// Now is injectable so tests can assert deterministic timestamps.
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an Engine rooted at root and creates docs/ and comments/ beneath
|
||||||
|
// it when they are missing.
|
||||||
|
func New(root string) (*Engine, error) {
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return nil, fmt.Errorf("storage: empty data root")
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: resolve data root: %w", err)
|
||||||
|
}
|
||||||
|
e := &Engine{
|
||||||
|
root: abs,
|
||||||
|
docsDir: filepath.Join(abs, docsDirName),
|
||||||
|
commentsDir: filepath.Join(abs, commentsDirName),
|
||||||
|
locks: make(map[string]*sync.Mutex),
|
||||||
|
Now: time.Now,
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(e.docsDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: create docs dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(e.commentsDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: create comments dir: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root returns the absolute data directory backing the engine.
|
||||||
|
func (e *Engine) Root() string { return e.root }
|
||||||
|
|
||||||
|
// DocsDir returns the absolute directory holding Markdown documents.
|
||||||
|
func (e *Engine) DocsDir() string { return e.docsDir }
|
||||||
|
|
||||||
|
// CommentsDir returns the absolute directory holding comment JSON files.
|
||||||
|
func (e *Engine) CommentsDir() string { return e.commentsDir }
|
||||||
|
|
||||||
|
func (e *Engine) now() time.Time {
|
||||||
|
if e.Now != nil {
|
||||||
|
return e.Now()
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockFor returns the mutex guarding a single slug. RLock-free by design: the
|
||||||
|
// critical sections are short, and per-slug granularity is what allows writes to
|
||||||
|
// different documents to proceed fully in parallel.
|
||||||
|
func (e *Engine) lockFor(slug string) *sync.Mutex {
|
||||||
|
e.locksMu.Lock()
|
||||||
|
defer e.locksMu.Unlock()
|
||||||
|
m, ok := e.locks[slug]
|
||||||
|
if !ok {
|
||||||
|
m = &sync.Mutex{}
|
||||||
|
e.locks[slug] = m
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRoot discovers the data directory without any configuration. It honours
|
||||||
|
// GO_DOC_DATA_DIR first, then walks up from the working directory looking for an
|
||||||
|
// existing data/ folder, and finally falls back to a relative "data".
|
||||||
|
func ResolveRoot() string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("GO_DOC_DATA_DIR")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err == nil {
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
candidate := filepath.Join(dir, "data")
|
||||||
|
if info, err := os.Stat(filepath.Join(candidate, docsDirName)); err == nil && info.IsDir() {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "data"
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultOnce sync.Once
|
||||||
|
defaultEng *Engine
|
||||||
|
defaultErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default returns the process-wide engine rooted at ResolveRoot().
|
||||||
|
func Default() (*Engine, error) {
|
||||||
|
defaultOnce.Do(func() {
|
||||||
|
defaultEng, defaultErr = New(ResolveRoot())
|
||||||
|
})
|
||||||
|
return defaultEng, defaultErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetDefault drops the cached engine so the next Default call re-resolves the
|
||||||
|
// root. Tests use it between suites that point at different temp directories.
|
||||||
|
func ResetDefault() {
|
||||||
|
defaultOnce = sync.Once{}
|
||||||
|
defaultEng = nil
|
||||||
|
defaultErr = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefault installs root as the process-wide engine.
|
||||||
|
func SetDefault(root string) (*Engine, error) {
|
||||||
|
e, err := New(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ResetDefault()
|
||||||
|
defaultOnce.Do(func() {})
|
||||||
|
defaultEng, defaultErr = e, nil
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// latinFold maps lower-case Latin letters carrying diacritics, plus common
|
||||||
|
// ligatures, onto their ASCII skeleton. Folding happens after ToLower, so the
|
||||||
|
// keys are always lower case.
|
||||||
|
var latinFold = map[rune]rune{
|
||||||
|
'á': 'a', 'à': 'a', 'â': 'a', 'ä': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ă': 'a', 'ą': 'a',
|
||||||
|
'ç': 'c', 'ć': 'c', 'č': 'c', 'ĉ': 'c', 'ċ': 'c',
|
||||||
|
'ď': 'd', 'đ': 'd',
|
||||||
|
'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'ē': 'e', 'ė': 'e', 'ę': 'e', 'ě': 'e',
|
||||||
|
'ğ': 'g', 'ĝ': 'g', 'ġ': 'g', 'ģ': 'g',
|
||||||
|
'í': 'i', 'ì': 'i', 'î': 'i', 'ï': 'i', 'ī': 'i', 'į': 'i', 'ı': 'i',
|
||||||
|
'ĵ': 'j',
|
||||||
|
'ķ': 'k',
|
||||||
|
'ł': 'l', 'ľ': 'l', 'ĺ': 'l',
|
||||||
|
'ñ': 'n', 'ń': 'n', 'ň': 'n',
|
||||||
|
'ó': 'o', 'ò': 'o', 'ô': 'o', 'ö': 'o', 'õ': 'o', 'ø': 'o', 'ō': 'o', 'ő': 'o',
|
||||||
|
'ŕ': 'r', 'ř': 'r',
|
||||||
|
'ś': 's', 'š': 's', 'ş': 's', 'ș': 's',
|
||||||
|
'ß': 's', 'þ': 't', 'ť': 't',
|
||||||
|
'ú': 'u', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ū': 'u', 'ů': 'u', 'ű': 'u',
|
||||||
|
'ŵ': 'w', 'ý': 'y', 'ÿ': 'y', 'ŷ': 'y',
|
||||||
|
'ź': 'z', 'ż': 'z', 'ž': 'z',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slugify converts an arbitrary human title into a lower-case, hyphen-separated
|
||||||
|
// slug: "Core Service API" becomes "core-service-api". Diacritics are folded to
|
||||||
|
// ASCII, every other separator run collapses into a single hyphen, and the result
|
||||||
|
// is trimmed and length-capped.
|
||||||
|
func Slugify(title string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(title))
|
||||||
|
|
||||||
|
pendingSep := true // suppresses a leading hyphen
|
||||||
|
for _, r := range strings.ToLower(title) {
|
||||||
|
if folded, ok := latinFold[r]; ok {
|
||||||
|
r = folded
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||||
|
b.WriteRune(r)
|
||||||
|
pendingSep = false
|
||||||
|
default:
|
||||||
|
// Everything else -- punctuation, whitespace, non-ASCII letters that
|
||||||
|
// carry no ASCII skeleton, and invalid UTF-8 -- collapses into a single
|
||||||
|
// separator so slugs stay safe in URLs and filenames everywhere.
|
||||||
|
if !pendingSep {
|
||||||
|
b.WriteByte('-')
|
||||||
|
pendingSep = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := strings.Trim(b.String(), "-")
|
||||||
|
if utf8.RuneCountInString(slug) > maxSlugLen {
|
||||||
|
truncated := slug[:maxSlugLen]
|
||||||
|
for len(truncated) > 0 && truncated[len(truncated)-1] == '-' {
|
||||||
|
truncated = truncated[:len(truncated)-1]
|
||||||
|
}
|
||||||
|
slug = truncated
|
||||||
|
}
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeSlug validates and canonicalises a slug coming from a URL path.
|
||||||
|
// Path separators, traversal segments and control characters are rejected
|
||||||
|
// outright with ErrInvalidSlug; anything else is slugified so that
|
||||||
|
// "Core Service API" and "core-service-api" address the same document.
|
||||||
|
func NormalizeSlug(slug string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(slug)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", fmt.Errorf("%w: slug is empty", ErrInvalidSlug)
|
||||||
|
}
|
||||||
|
if len(trimmed) > 2*maxSlugLen {
|
||||||
|
return "", fmt.Errorf("%w: slug exceeds %d bytes", ErrInvalidSlug, 2*maxSlugLen)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(trimmed, "/\\\x00") || strings.Contains(trimmed, "..") {
|
||||||
|
return "", fmt.Errorf("%w: slug %q contains path separators", ErrInvalidSlug, slug)
|
||||||
|
}
|
||||||
|
for _, r := range trimmed {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return "", fmt.Errorf("%w: slug %q contains control characters", ErrInvalidSlug, slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := Slugify(trimmed)
|
||||||
|
if normalized == "" {
|
||||||
|
return "", fmt.Errorf("%w: slug %q has no usable characters", ErrInvalidSlug, slug)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var strictSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||||
|
|
||||||
|
// IsCanonicalSlug reports whether slug is already in the canonical form produced
|
||||||
|
// by Slugify. Handlers use it to decide whether a rename touched the filesystem.
|
||||||
|
func IsCanonicalSlug(slug string) bool { return strictSlug.MatchString(slug) }
|
||||||
|
|
||||||
|
func (e *Engine) docPath(slug string) (string, error) {
|
||||||
|
normalized, err := NormalizeSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(e.docsDir, normalized+DocExt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDoc writes content to data/docs/<slug>.md, creating the document when it
|
||||||
|
// does not exist and replacing it wholesale otherwise. The write is atomic and
|
||||||
|
// therefore safe to call concurrently with readers.
|
||||||
|
func (e *Engine) SaveDoc(slug, content string) (string, error) {
|
||||||
|
normalized, err := NormalizeSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||||||
|
|
||||||
|
mu := e.lockFor(normalized)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
normalizedContent := NormalizeMarkdown(content)
|
||||||
|
if err := writeFileAtomic(path, []byte(normalizedContent), 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeMarkdown converts CRLF/CR line endings to LF, guarantees a single
|
||||||
|
// trailing newline, and leaves the author's content otherwise untouched.
|
||||||
|
func NormalizeMarkdown(content string) string {
|
||||||
|
if content == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||||
|
normalized = strings.TrimRight(normalized, "\n") + "\n"
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDoc returns the raw Markdown stored at data/docs/<slug>.doc.
|
||||||
|
// A missing document yields ErrNotFound.
|
||||||
|
func (e *Engine) ReadDoc(slug string) (string, error) {
|
||||||
|
path, err := e.docPath(slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return "", fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("storage: read doc %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDoc permanently removes data/docs/<slug>.md together with the document's
|
||||||
|
// comment file. Removing a document that does not exist yields ErrNotFound.
|
||||||
|
func (e *Engine) DeleteDoc(slug string) error {
|
||||||
|
normalized, err := NormalizeSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||||||
|
|
||||||
|
mu := e.lockFor(normalized)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("storage: delete doc %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade the comment file so a recreated slug does not inherit stale history.
|
||||||
|
commentPath := e.commentPath(normalized)
|
||||||
|
if err := os.Remove(commentPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("storage: delete comments for %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocMeta is the index entry describing one stored document.
|
||||||
|
type DocMeta struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Comments int `json:"comments"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var headingPattern = regexp.MustCompile(`(?m)^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$`)
|
||||||
|
|
||||||
|
// ExtractTitle returns the first Markdown heading in content, falling back to a
|
||||||
|
// title-cased rendering of the slug.
|
||||||
|
func ExtractTitle(content, slug string) string {
|
||||||
|
if len(content) > maxTitleScanBytes {
|
||||||
|
content = content[:maxTitleScanBytes]
|
||||||
|
}
|
||||||
|
if m := headingPattern.FindStringSubmatch(content); m != nil {
|
||||||
|
if title := strings.TrimSpace(m[1]); title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TitleizeSlug(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TitleizeSlug renders a slug as a human title: "core-service-api" becomes
|
||||||
|
// "Core Service API".
|
||||||
|
func TitleizeSlug(slug string) string {
|
||||||
|
words := strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' })
|
||||||
|
for i, w := range words {
|
||||||
|
if w == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runes := []rune(strings.ToLower(w))
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
words[i] = string(runes)
|
||||||
|
}
|
||||||
|
if len(words) == 0 {
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
return strings.Join(words, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocExists reports whether a document is present on disk.
|
||||||
|
func (e *Engine) DocExists(slug string) (bool, error) {
|
||||||
|
path, err := e.docPath(slug)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("storage: stat doc %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
return !info.IsDir(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns index metadata for a single document.
|
||||||
|
func (e *Engine) Stat(slug string) (DocMeta, error) {
|
||||||
|
normalized, err := NormalizeSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return DocMeta{}, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return DocMeta{}, fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||||||
|
}
|
||||||
|
return DocMeta{}, fmt.Errorf("storage: stat doc %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := DocMeta{Slug: normalized, Size: info.Size(), Modified: info.ModTime().UTC()}
|
||||||
|
if head, err := os.ReadFile(path); err == nil {
|
||||||
|
meta.Title = ExtractTitle(string(head), normalized)
|
||||||
|
} else {
|
||||||
|
meta.Title = TitleizeSlug(normalized)
|
||||||
|
}
|
||||||
|
if comments, err := e.ListComments(normalized); err == nil {
|
||||||
|
meta.Comments = len(comments)
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDocs indexes every Markdown document on disk, newest modification first.
|
||||||
|
// It reads only the head of each file to recover the document title, so listing
|
||||||
|
// hundreds of documents stays cheap; use ListDocsDetailed when comment counts are
|
||||||
|
// needed as well.
|
||||||
|
func (e *Engine) ListDocs() ([]DocMeta, error) { return e.listDocs(false) }
|
||||||
|
|
||||||
|
// ListDocsDetailed is ListDocs with per-document comment counts filled in.
|
||||||
|
func (e *Engine) ListDocsDetailed() ([]DocMeta, error) { return e.listDocs(true) }
|
||||||
|
|
||||||
|
func (e *Engine) listDocs(withComments bool) ([]DocMeta, error) {
|
||||||
|
entries, err := os.ReadDir(e.docsDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return []DocMeta{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("storage: list docs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metas := make([]DocMeta, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(name, DocExt) || strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slug := strings.TrimSuffix(name, DocExt)
|
||||||
|
if !IsCanonicalSlug(slug) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta := DocMeta{
|
||||||
|
Slug: slug,
|
||||||
|
Title: e.readTitle(filepath.Join(e.docsDir, name), slug),
|
||||||
|
Size: info.Size(),
|
||||||
|
Modified: info.ModTime().UTC(),
|
||||||
|
}
|
||||||
|
if withComments {
|
||||||
|
if comments, err := e.ListComments(slug); err == nil {
|
||||||
|
meta.Comments = len(comments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metas = append(metas, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(metas, func(i, j int) bool {
|
||||||
|
if metas[i].Modified.Equal(metas[j].Modified) {
|
||||||
|
return metas[i].Slug < metas[j].Slug
|
||||||
|
}
|
||||||
|
return metas[i].Modified.After(metas[j].Modified)
|
||||||
|
})
|
||||||
|
return metas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search returns documents whose slug or extracted title contains query,
|
||||||
|
// case-insensitively. An empty query lists everything.
|
||||||
|
func (e *Engine) Search(query string) ([]DocMeta, error) {
|
||||||
|
metas, err := e.ListDocs()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
needle := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
if needle == "" {
|
||||||
|
return metas, nil
|
||||||
|
}
|
||||||
|
matches := make([]DocMeta, 0, len(metas))
|
||||||
|
for _, meta := range metas {
|
||||||
|
if strings.Contains(strings.ToLower(meta.Slug), needle) ||
|
||||||
|
strings.Contains(strings.ToLower(meta.Title), needle) {
|
||||||
|
matches = append(matches, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTitle recovers a document title from the head of the file, reading at most
|
||||||
|
// maxTitleScanBytes so a huge document cannot slow the index down. Any failure
|
||||||
|
// degrades to a title derived from the slug.
|
||||||
|
func (e *Engine) readTitle(path, slug string) string {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return TitleizeSlug(slug)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
head := make([]byte, maxTitleScanBytes)
|
||||||
|
n, err := file.Read(head)
|
||||||
|
if n <= 0 || (err != nil && n == 0) {
|
||||||
|
return TitleizeSlug(slug)
|
||||||
|
}
|
||||||
|
return ExtractTitle(string(head[:n]), slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileAtomic writes data to path via a temporary file in the same directory,
|
||||||
|
// fsyncs it, renames it into place, and fsyncs the parent directory so the
|
||||||
|
// rename survives a crash. Readers therefore see either the old or the new file.
|
||||||
|
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".godoc-tmp-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage: create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("storage: write temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("storage: sync temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return fmt.Errorf("storage: close temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmpName, perm); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return fmt.Errorf("storage: chmod temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return fmt.Errorf("storage: rename into place: %w", err)
|
||||||
|
}
|
||||||
|
if err := syncDir(dir); err != nil {
|
||||||
|
return fmt.Errorf("storage: sync dir: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDir flushes a directory entry so a completed rename is durable.
|
||||||
|
func syncDir(dir string) error {
|
||||||
|
f, err := os.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return f.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDoc writes content to data/docs/<slug>.md through the process-wide engine.
|
||||||
|
func SaveDoc(slug, content string) (string, error) {
|
||||||
|
e, err := Default()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return e.SaveDoc(slug, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDoc reads data/docs/<slug>.md through the process-wide engine.
|
||||||
|
func ReadDoc(slug string) (string, error) {
|
||||||
|
e, err := Default()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return e.ReadDoc(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDoc removes data/docs/<slug>.md through the process-wide engine.
|
||||||
|
func DeleteDoc(slug string) error {
|
||||||
|
e, err := Default()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.DeleteDoc(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDocs indexes documents through the process-wide engine.
|
||||||
|
func ListDocs() ([]DocMeta, error) {
|
||||||
|
e, err := Default()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e.ListDocs()
|
||||||
|
}
|
||||||
578
backend/storage/engine_test.go
Normal file
578
backend/storage/engine_test.go
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestEngine(t *testing.T) *Engine {
|
||||||
|
t.Helper()
|
||||||
|
engine, err := New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlugify(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Core Service API", "core-service-api"},
|
||||||
|
{"core-service-api", "core-service-api"},
|
||||||
|
{" Spaced Out Title ", "spaced-out-title"},
|
||||||
|
{"Deploy & Rollback", "deploy-rollback"},
|
||||||
|
{"API_v2 (Internal)", "api-v2-internal"},
|
||||||
|
{"Release 2024/Q1 -- Notes", "release-2024-q1-notes"},
|
||||||
|
{"Überwachen der Dienste", "uberwachen-der-dienste"},
|
||||||
|
{"Straße", "strase"},
|
||||||
|
{"C++ & Rust: side by side", "c-rust-side-by-side"},
|
||||||
|
{"emoji 🚀 launch", "emoji-launch"},
|
||||||
|
{"multiple---hyphens", "multiple-hyphens"},
|
||||||
|
{"trailing hyphen-", "trailing-hyphen"},
|
||||||
|
{"---leading", "leading"},
|
||||||
|
{"!!!", ""},
|
||||||
|
{"", ""},
|
||||||
|
{"日本語 title", "title"},
|
||||||
|
{"snake_case_and__doubles", "snake-case-and-doubles"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := Slugify(tc.input); got != tc.want {
|
||||||
|
t.Errorf("Slugify(%q) = %q, want %q", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlugifyCapsLength(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
long := strings.Repeat("word ", 100)
|
||||||
|
got := Slugify(long)
|
||||||
|
if len(got) > maxSlugLen {
|
||||||
|
t.Fatalf("slug length %d exceeds the %d cap", len(got), maxSlugLen)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(got, "-") {
|
||||||
|
t.Fatalf("capped slug must not end in a hyphen: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSlug(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
valid := map[string]string{
|
||||||
|
"Core Service API": "core-service-api",
|
||||||
|
"core-service-api": "core-service-api",
|
||||||
|
" Spaced ": "spaced",
|
||||||
|
"Ünïcödé Slug": "unicode-slug",
|
||||||
|
}
|
||||||
|
for input, want := range valid {
|
||||||
|
got, err := NormalizeSlug(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("NormalizeSlug(%q) returned %v", input, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("NormalizeSlug(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"../etc/passwd",
|
||||||
|
"..",
|
||||||
|
"nested/path",
|
||||||
|
"back\\slash",
|
||||||
|
"null\x00byte",
|
||||||
|
"!!!",
|
||||||
|
strings.Repeat("x", 2*maxSlugLen+1),
|
||||||
|
}
|
||||||
|
for _, input := range invalid {
|
||||||
|
if _, err := NormalizeSlug(input); !errors.Is(err, ErrInvalidSlug) {
|
||||||
|
t.Errorf("NormalizeSlug(%q) should be rejected, got %v", input, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveReadDeleteDoc(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
slug, err := engine.SaveDoc("Core Service API", "# Core Service API\n\nBody.\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveDoc: %v", err)
|
||||||
|
}
|
||||||
|
if slug != "core-service-api" {
|
||||||
|
t.Fatalf("expected core-service-api, got %q", slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(engine.DocsDir(), "core-service-api.md")); err != nil {
|
||||||
|
t.Fatalf("Markdown file missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := engine.ReadDoc("Core Service API")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDoc: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "Body.") {
|
||||||
|
t.Fatalf("unexpected content: %q", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := engine.ReadDoc("never-saved"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := engine.DeleteDoc("Core Service API"); err != nil {
|
||||||
|
t.Fatalf("DeleteDoc: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := engine.ReadDoc("core-service-api"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||||
|
}
|
||||||
|
if err := engine.DeleteDoc("core-service-api"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound on a second delete, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteDocCascadesComments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
if _, err := engine.SaveDoc("doomed", "# Doomed\n"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.AppendComment("doomed", CommentInput{Author: "a", Role: "user", Content: "hi"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
commentPath := filepath.Join(engine.CommentsDir(), "doomed-comments.json")
|
||||||
|
if _, err := os.Stat(commentPath); err != nil {
|
||||||
|
t.Fatalf("expected the comment file to exist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := engine.DeleteDoc("doomed"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(commentPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected the comment file to be removed, stat returned %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDocNormalizesLineEndings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
if _, err := engine.SaveDoc("crlf", "line one\r\nline two\r\n\r\n"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := engine.ReadDoc("crlf")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "\r") {
|
||||||
|
t.Fatalf("carriage returns survived: %q", content)
|
||||||
|
}
|
||||||
|
if content != "line one\nline two\n" {
|
||||||
|
t.Fatalf("unexpected normalization: %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentBadges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[Role]string{
|
||||||
|
RoleUser: "🟢",
|
||||||
|
RoleOperator: "🔵",
|
||||||
|
RoleDeveloper: "🔴",
|
||||||
|
}
|
||||||
|
for role, badge := range cases {
|
||||||
|
if role.Badge() != badge {
|
||||||
|
t.Errorf("role %s badge = %q, want %q", role, role.Badge(), badge)
|
||||||
|
}
|
||||||
|
if !role.Valid() {
|
||||||
|
t.Errorf("role %s should be valid", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Role("admin").Badge() != "" || Role("admin").Valid() {
|
||||||
|
t.Error("an unknown role must have no badge and be invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range []string{"user", "USER", " User "} {
|
||||||
|
if role, err := ParseRole(raw); err != nil || role != RoleUser {
|
||||||
|
t.Errorf("ParseRole(%q) = %v, %v", raw, role, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ParseRole("root"); !errors.Is(err, ErrInvalidRole) {
|
||||||
|
t.Errorf("expected ErrInvalidRole, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendCommentValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
if _, err := engine.AppendComment("valid", CommentInput{Role: "developer", Content: " "}); !errors.Is(err, ErrInvalidComment) {
|
||||||
|
t.Errorf("blank content should be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := engine.AppendComment("valid", CommentInput{Role: "wizard", Content: "hello"}); !errors.Is(err, ErrInvalidRole) {
|
||||||
|
t.Errorf("unknown role should be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := engine.AppendComment("../escape", CommentInput{Role: "user", Content: "hi"}); !errors.Is(err, ErrInvalidSlug) {
|
||||||
|
t.Errorf("traversal slug should be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment, err := engine.AppendComment("valid", CommentInput{Role: "user", Content: "hello"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AppendComment: %v", err)
|
||||||
|
}
|
||||||
|
if comment.Author != "User" {
|
||||||
|
t.Errorf("an omitted author should default to the role label, got %q", comment.Author)
|
||||||
|
}
|
||||||
|
if comment.Badge != "🟢" {
|
||||||
|
t.Errorf("expected the user badge, got %q", comment.Badge)
|
||||||
|
}
|
||||||
|
if comment.ID == "" || comment.Timestamp.IsZero() {
|
||||||
|
t.Error("expected an ID and a timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentsAreSortedAndDeduplicatedByID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
for i, offset := range []int{30, 10, 20} {
|
||||||
|
if _, err := engine.AppendComment("ordered", CommentInput{
|
||||||
|
Author: fmt.Sprintf("user-%d", i),
|
||||||
|
Role: "user",
|
||||||
|
Content: fmt.Sprintf("comment %d", i),
|
||||||
|
Timestamp: base.Add(time.Duration(offset) * time.Minute),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
comments, err := engine.ListComments("ordered")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(comments) != 3 {
|
||||||
|
t.Fatalf("expected 3 comments, got %d", len(comments))
|
||||||
|
}
|
||||||
|
for i := 1; i < len(comments); i++ {
|
||||||
|
if comments[i].Timestamp.Before(comments[i-1].Timestamp) {
|
||||||
|
t.Fatalf("comments are not in chronological order: %+v", comments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(comments[0].Content) != "comment 1" {
|
||||||
|
t.Fatalf("expected the earliest comment first, got %q", comments[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteComment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
first, err := engine.AppendComment("prune", CommentInput{Role: "user", Content: "first"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.AppendComment("prune", CommentInput{Role: "user", Content: "second"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
removed, err := engine.DeleteComment("prune", first.ID)
|
||||||
|
if err != nil || !removed {
|
||||||
|
t.Fatalf("expected the comment to be removed, got %v / %v", removed, err)
|
||||||
|
}
|
||||||
|
removed, err = engine.DeleteComment("prune", "does-not-exist")
|
||||||
|
if err != nil || removed {
|
||||||
|
t.Fatalf("deleting an unknown ID must be a no-op, got %v / %v", removed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining, err := engine.ListComments("prune")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(remaining) != 1 || strings.TrimSpace(remaining[0].Content) != "second" {
|
||||||
|
t.Fatalf("unexpected remaining comments: %+v", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAndSearchDocs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
for _, title := range []string{"Alpha Service", "Beta Service", "Gamma Gateway"} {
|
||||||
|
if _, err := engine.SaveDoc(title, "# "+title+"\n"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := engine.AppendComment("alpha-service", CommentInput{Role: "user", Content: "hi"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := engine.ListDocs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("expected 3 documents, got %d", len(all))
|
||||||
|
}
|
||||||
|
// Titles come from the document's H1, not from a slug-derived guess.
|
||||||
|
for _, doc := range all {
|
||||||
|
if doc.Title != "Alpha Service" && doc.Title != "Beta Service" && doc.Title != "Gamma Gateway" {
|
||||||
|
t.Fatalf("document %s has title %q; expected the H1 heading", doc.Slug, doc.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detailed, err := engine.ListDocsDetailed()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, doc := range detailed {
|
||||||
|
if doc.Slug == "alpha-service" && doc.Comments != 1 {
|
||||||
|
t.Fatalf("expected 1 comment on alpha-service, got %d", doc.Comments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hits, err := engine.Search("gateway")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(hits) != 1 || hits[0].Slug != "gamma-gateway" {
|
||||||
|
t.Fatalf("unexpected search hits: %+v", hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temp files left by an interrupted write must never surface in the index.
|
||||||
|
if err := os.WriteFile(filepath.Join(engine.DocsDir(), ".godoc-tmp-123"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(engine.DocsDir(), "not-markdown.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all, err = engine.ListDocs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("index must ignore temp and non-Markdown files, got %d entries", len(all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTitleAndTitleize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
content string
|
||||||
|
slug string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"# Core Service API\n\nbody", "core-service-api", "Core Service API"},
|
||||||
|
{"## Only an H2\n\nbody", "only-an-h2", "Only an H2"},
|
||||||
|
{"no heading here", "plain-slug", "Plain Slug"},
|
||||||
|
{"", "empty-slug", "Empty Slug"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := ExtractTitle(tc.content, tc.slug); got != tc.want {
|
||||||
|
t.Errorf("ExtractTitle(%q, %q) = %q, want %q", tc.content, tc.slug, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentAppendsLoseNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
writers = 50
|
||||||
|
perDoc = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, writers)
|
||||||
|
|
||||||
|
for w := 0; w < writers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(worker int) {
|
||||||
|
defer wg.Done()
|
||||||
|
role := Roles()[worker%3]
|
||||||
|
for n := 0; n < perDoc; n++ {
|
||||||
|
if _, err := engine.AppendComment("hot", CommentInput{
|
||||||
|
Author: fmt.Sprintf("worker-%02d", worker),
|
||||||
|
Role: string(role),
|
||||||
|
Content: fmt.Sprintf("note %d from %d", n, worker),
|
||||||
|
}); err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
for err := range errs {
|
||||||
|
t.Fatalf("concurrent append failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
comments, err := engine.ListComments("hot")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(comments) != writers*perDoc {
|
||||||
|
t.Fatalf("expected %d comments, got %d", writers*perDoc, len(comments))
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(comments))
|
||||||
|
for _, comment := range comments {
|
||||||
|
key := comment.Author + "|" + comment.Content
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
t.Fatalf("duplicate entry after concurrent writes: %s", key)
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentReadsNeverSeeTornWrites(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
const marker = "SENTINEL-HEADER\n"
|
||||||
|
body := strings.Repeat("payload line\n", 500)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, 32)
|
||||||
|
|
||||||
|
for w := 0; w < 16; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(worker int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for n := 0; n < 25; n++ {
|
||||||
|
if _, err := engine.SaveDoc("live", marker+body); err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
for r := 0; r < 16; r++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for n := 0; n < 100; n++ {
|
||||||
|
content, err := engine.ReadDoc("live")
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
errs <- err
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(content, marker) {
|
||||||
|
errs <- fmt.Errorf("torn read: %q", content[:min(len(content), 40)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
for err := range errs {
|
||||||
|
t.Fatalf("concurrent access failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(engine.DocsDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".godoc-tmp-") {
|
||||||
|
t.Fatalf("atomic write leaked a temp file: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
if _, err := engine.SaveDoc("counted", "# Counted\n"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, role := range []Role{RoleUser, RoleUser, RoleOperator, RoleDeveloper, RoleDeveloper, RoleDeveloper} {
|
||||||
|
if _, err := engine.AppendComment("counted", CommentInput{
|
||||||
|
Author: fmt.Sprintf("author-%d", i),
|
||||||
|
Role: string(role),
|
||||||
|
Content: fmt.Sprintf("comment %d", i),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := engine.Stats("counted")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stats.Total != 6 {
|
||||||
|
t.Fatalf("expected 6 comments, got %d", stats.Total)
|
||||||
|
}
|
||||||
|
if stats.ByRole["developer"] != 3 || stats.ByRole["user"] != 2 || stats.ByRole["operator"] != 1 {
|
||||||
|
t.Fatalf("unexpected tally: %+v", stats.ByRole)
|
||||||
|
}
|
||||||
|
if stats.MostActive != "developer" {
|
||||||
|
t.Fatalf("expected developer as the most active role, got %q", stats.MostActive)
|
||||||
|
}
|
||||||
|
if stats.OldestAt == nil || stats.LatestAt == nil {
|
||||||
|
t.Fatal("expected the time bounds to be populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEngineSatisfiesDocumentedInterface(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
exists, err := engine.DocExists("Core Service API")
|
||||||
|
if err != nil || exists {
|
||||||
|
t.Fatalf("DocExists before save = %v / %v", exists, err)
|
||||||
|
}
|
||||||
|
if _, err := engine.SaveDoc("Core Service API", "# Core Service API\n"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exists, err = engine.DocExists("core-service-api")
|
||||||
|
if err != nil || !exists {
|
||||||
|
t.Fatalf("DocExists after save = %v / %v", exists, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := engine.Stat("core-service-api")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.Title != "Core Service API" {
|
||||||
|
t.Fatalf("Stat title = %q", meta.Title)
|
||||||
|
}
|
||||||
|
if meta.Size <= 0 || meta.Modified.IsZero() {
|
||||||
|
t.Fatalf("Stat returned incomplete metadata: %+v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRootHonoursEnvironment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("GO_DOC_DATA_DIR", dir)
|
||||||
|
if got := ResolveRoot(); got != dir {
|
||||||
|
t.Fatalf("ResolveRoot() = %q, want %q", got, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := New(" "); err == nil {
|
||||||
|
t.Fatal("New must reject an empty root")
|
||||||
|
}
|
||||||
|
}
|
||||||
295
backend/tests/bdd/documents.feature_test.go
Normal file
295
backend/tests/bdd/documents.feature_test.go
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
package bdd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFeatureSlugifiedMarkdownStorage covers:
|
||||||
|
//
|
||||||
|
// Given a new application title
|
||||||
|
// When it is saved
|
||||||
|
// Then it should be slugified and written to a Markdown file
|
||||||
|
func TestFeatureSlugifiedMarkdownStorage(t *testing.T) {
|
||||||
|
Run(t, Feature{
|
||||||
|
Name: "Slugified Markdown storage",
|
||||||
|
Scenarios: []Scenario{
|
||||||
|
{
|
||||||
|
Name: "a new application title is slugified and written to a Markdown file",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: `a new application title "Core Service API"`, Do: func(c *Context) error {
|
||||||
|
return ExpectEqual(storage.Slugify("Core Service API"), "core-service-api", "slugified title")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "the title is saved", Do: func(c *Context) error {
|
||||||
|
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nHandles billing.\n")
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the engine reports the canonical slug", Do: func(c *Context) error {
|
||||||
|
meta, err := c.Engine.Stat("Core Service API")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectEqual(meta.Slug, "core-service-api", "canonical slug")
|
||||||
|
}},
|
||||||
|
{Text: "data/docs/core-service-api.md exists", Do: func(c *Context) error {
|
||||||
|
exists, err := c.FileExists(c.DocPath("core-service-api"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return errf("expected %s to exist", c.DocPath("core-service-api"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "the Markdown content is stored verbatim", Do: func(c *Context) error {
|
||||||
|
content, err := c.RawDoc("core-service-api")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectContains(content, "# Core Service API", "stored markdown")
|
||||||
|
}},
|
||||||
|
{Text: "no file is created for the un-slugified title", Do: func(c *Context) error {
|
||||||
|
exists, err := c.FileExists(c.DocPath("Core Service API"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return errf("a non-slugified filename was created")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "titles with punctuation, casing and diacritics are normalised",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a batch of awkward application titles", Do: func(c *Context) error {
|
||||||
|
cases := map[string]string{
|
||||||
|
" Core Service API ": "core-service-api",
|
||||||
|
"Deploy & Rollback": "deploy-rollback",
|
||||||
|
"Überwachen der Dienste": "uberwachen-der-dienste",
|
||||||
|
"API_v2 (Internal)": "api-v2-internal",
|
||||||
|
"!!!": "",
|
||||||
|
"Release 2024/Q1 -- Notes": "release-2024-q1-notes",
|
||||||
|
"User Facing / End Users": "user-facing-end-users",
|
||||||
|
"already-a-canonical-slug-1": "already-a-canonical-slug-1",
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := storage.Slugify(input); got != want {
|
||||||
|
return errf("Slugify(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "each title is saved", Do: func(c *Context) error {
|
||||||
|
for _, title := range []string{"Deploy & Rollback", "Überwachen der Dienste", "API_v2 (Internal)"} {
|
||||||
|
if _, err := c.Engine.SaveDoc(title, "# "+title+"\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "each slugified filename exists on disk", Do: func(c *Context) error {
|
||||||
|
for _, slug := range []string{"deploy-rollback", "uberwachen-der-dienste", "api-v2-internal"} {
|
||||||
|
exists, err := c.FileExists(c.DocPath(slug))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return errf("expected data/docs/%s.md to exist", slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "saving through the API slugifies the slug taken from the URL",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "the API is running against a temporary data directory", Do: func(c *Context) error {
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: `a document is POSTed to /api/docs/Core Service API`, Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do(
|
||||||
|
"POST", "/api/docs/Core%20Service%20API", "text/markdown", "# Core Service API\n",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ExpectStatus(response, 201); err != nil {
|
||||||
|
return errf("%w (body: %s)", err, payload)
|
||||||
|
}
|
||||||
|
saved, err := Decode[struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}](payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Remember("slug", saved.Slug)
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the response reports the slugified slug", Do: func(c *Context) error {
|
||||||
|
return ExpectEqual(c.RecallAs[string]("slug"), "core-service-api", "response slug")
|
||||||
|
}},
|
||||||
|
{Text: "the Markdown file is on disk", Do: func(c *Context) error {
|
||||||
|
exists, err := c.FileExists(c.DocPath("core-service-api"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return errf("data/docs/core-service-api.md was not created")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "GET /api/docs/core-service-api returns the raw Markdown", Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do("GET", "/api/docs/core-service-api", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ExpectStatus(response, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if contentType := response.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "text/markdown") {
|
||||||
|
return errf("expected text/markdown, got %q", contentType)
|
||||||
|
}
|
||||||
|
return ExpectContains(string(payload), "# Core Service API", "markdown response")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "saving an existing document replaces its content wholesale",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "an existing document with two sections", Do: func(c *Context) error {
|
||||||
|
_, err := c.Engine.SaveDoc("release-runbook", "# Runbook\n\n## Deploy\n\nstep\n\n## Rollback\n\nstep\n")
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "the document is rewritten with a single section", Do: func(c *Context) error {
|
||||||
|
response, _, err := c.Do("POST", "/api/docs/release-runbook", "text/markdown", "# Runbook v2\n\n## Deploy only\n")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectStatus(response, 200)
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the previous sections are gone", Do: func(c *Context) error {
|
||||||
|
content, err := c.RawDoc("release-runbook")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "## Rollback") {
|
||||||
|
return errf("stale section survived the update: %q", content)
|
||||||
|
}
|
||||||
|
return ExpectContains(content, "## Deploy only", "updated markdown")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "deleting a document removes the file and its feedback",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a document that already has comments", Do: func(c *Context) error {
|
||||||
|
if _, err := c.Engine.SaveDoc("obsolete-service", "# Obsolete\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := c.Engine.AppendComment("obsolete-service", storage.CommentInput{
|
||||||
|
Author: "ana",
|
||||||
|
Role: "user",
|
||||||
|
Content: "is this still needed?",
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "DELETE /api/docs/obsolete-service is called", Do: func(c *Context) error {
|
||||||
|
response, _, err := c.Do("DELETE", "/api/docs/obsolete-service", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectStatus(response, 204)
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the Markdown file is gone", Do: func(c *Context) error {
|
||||||
|
exists, err := c.FileExists(c.DocPath("obsolete-service"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return errf("data/docs/obsolete-service.md still exists")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "the comment file is gone", Do: func(c *Context) error {
|
||||||
|
exists, err := c.FileExists(c.CommentsPath("obsolete-service"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return errf("the feedback file was not cascaded")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "reading the deleted document returns 404", Do: func(c *Context) error {
|
||||||
|
response, _, err := c.Do("GET", "/api/docs/obsolete-service", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectStatus(response, 404)
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "path traversal in a slug is rejected",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a request for a traversal slug", Do: func(c *Context) error {
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: `POST /api/docs/..%2F..%2Fetc%2Fpasswd is sent`, Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do("POST", "/api/docs/..%2F..%2Fetc%2Fpasswd", "text/markdown", "nope")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Remember("status", response.StatusCode)
|
||||||
|
c.Remember("body", string(payload))
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the request is rejected with 400", Do: func(c *Context) error {
|
||||||
|
return ExpectEqual(c.RecallAs[int]("status"), 400, "status code")
|
||||||
|
}},
|
||||||
|
{Text: "nothing is written outside data/docs", Do: func(c *Context) error {
|
||||||
|
for _, name := range []string{"etc", "passwd", ".."} {
|
||||||
|
exists, err := c.FileExists(c.DocPath(name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return errf("unexpected file created for slug %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
360
backend/tests/bdd/feedback.feature_test.go
Normal file
360
backend/tests/bdd/feedback.feature_test.go
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
package bdd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFeatureBadgedCommentInjection covers:
|
||||||
|
//
|
||||||
|
// Given an existing documentation page
|
||||||
|
// When a developer adds a comment with a developer badge
|
||||||
|
// Then it should be appended to the JSON array and rendered with a red visual badge
|
||||||
|
func TestFeatureBadgedCommentInjection(t *testing.T) {
|
||||||
|
Run(t, Feature{
|
||||||
|
Name: "Badged comment injection",
|
||||||
|
Scenarios: []Scenario{
|
||||||
|
{
|
||||||
|
Name: "a developer comment is appended to the JSON array with a red badge",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "an existing documentation page for Core Service API", Do: func(c *Context) error {
|
||||||
|
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nBilling endpoints.\n")
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "a developer posts a comment through POST /api/docs/core-service-api/comments", Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do(
|
||||||
|
"POST", "/api/docs/core-service-api/comments", "application/json",
|
||||||
|
`{"author":"ana","role":"Developer","content":"The retry budget is undocumented."}`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ExpectStatus(response, 201); err != nil {
|
||||||
|
return errf("%w (body: %s)", err, payload)
|
||||||
|
}
|
||||||
|
created, err := Decode[struct {
|
||||||
|
Comment storage.Comment `json:"comment"`
|
||||||
|
Badge string `json:"badge"`
|
||||||
|
}](payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Remember("comment", created.Comment)
|
||||||
|
c.Remember("badge", created.Badge)
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the comment is stored with the developer role", Do: func(c *Context) error {
|
||||||
|
comment := c.RecallAs[storage.Comment]("comment")
|
||||||
|
return ExpectEqual(comment.Role, storage.RoleDeveloper, "stored role")
|
||||||
|
}},
|
||||||
|
{Text: "the comment carries the red 🔴 developer badge", Do: func(c *Context) error {
|
||||||
|
return ExpectEqual(c.RecallAs[string]("badge"), badgeContract[storage.RoleDeveloper], "response badge")
|
||||||
|
}},
|
||||||
|
{Text: "data/comments/core-service-api-comments.json is a JSON array containing it", Do: func(c *Context) error {
|
||||||
|
raw, err := c.RawJSON("core-service-api")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.TrimLeft(string(raw), " \n\t"), JSONArrayPrefix) {
|
||||||
|
return errf("feedback file is not a JSON array: %q", string(raw))
|
||||||
|
}
|
||||||
|
comments, err := c.RawComments("core-service-api")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(comments) != 1 {
|
||||||
|
return errf("expected 1 stored comment, got %d", len(comments))
|
||||||
|
}
|
||||||
|
stored := comments[0]
|
||||||
|
if stored.ID == "" {
|
||||||
|
return errf("stored comment has no ID")
|
||||||
|
}
|
||||||
|
if stored.Author != "ana" {
|
||||||
|
return errf("expected author ana, got %q", stored.Author)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stored.Content, "retry budget") {
|
||||||
|
return errf("stored content mismatch: %q", stored.Content)
|
||||||
|
}
|
||||||
|
return ExpectEqual(stored.Badge, badgeContract[storage.RoleDeveloper], "stored badge")
|
||||||
|
}},
|
||||||
|
{Text: "the badge renders in the red developer colour #ef4444", Do: func(c *Context) error {
|
||||||
|
role := c.RecallAs[storage.Comment]("comment").Role
|
||||||
|
return ExpectEqual(role.Hex(), badgeHex[storage.RoleDeveloper], "badge colour")
|
||||||
|
}},
|
||||||
|
{Text: "GET /api/docs/core-service-api/comments returns the badged comment", Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do("GET", "/api/docs/core-service-api/comments", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ExpectStatus(response, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listed, err := Decode[struct {
|
||||||
|
Comments []storage.Comment `json:"comments"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}](payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if listed.Count != 1 {
|
||||||
|
return errf("expected count 1, got %d", listed.Count)
|
||||||
|
}
|
||||||
|
return ExpectEqual(listed.Comments[0].Badge, "🔴", "listed badge")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "all three audience badges are applied to their own roles",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a documentation page with an existing user comment", Do: func(c *Context) error {
|
||||||
|
if _, err := c.Engine.SaveDoc("deploy-pipeline", "# Deploy Pipeline\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := appendComment(c, "deploy-pipeline", storage.RoleUser, "sam", "The wizard is confusing")
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "an operator and a developer comment are added", Do: func(c *Context) error {
|
||||||
|
if _, err := appendComment(c, "deploy-pipeline", storage.RoleOperator, "robin", "Rollback takes 20 minutes"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := appendComment(c, "deploy-pipeline", storage.RoleDeveloper, "kit", "Add a --dry-run flag")
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the JSON array holds all three comments in insertion order", Do: func(c *Context) error {
|
||||||
|
comments, err := c.RawComments("deploy-pipeline")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(comments) != 3 {
|
||||||
|
return errf("expected 3 comments, got %d", len(comments))
|
||||||
|
}
|
||||||
|
want := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||||||
|
for i, role := range want {
|
||||||
|
if comments[i].Role != role {
|
||||||
|
return errf("comment %d: expected role %s, got %s", i, role, comments[i].Role)
|
||||||
|
}
|
||||||
|
if comments[i].Badge != badgeContract[role] {
|
||||||
|
return errf("comment %d: expected badge %s, got %s", i, badgeContract[role], comments[i].Badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "each badge matches its role colour", Do: func(c *Context) error {
|
||||||
|
comments, err := c.RawComments("deploy-pipeline")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, comment := range comments {
|
||||||
|
if comment.Role.Hex() != badgeHex[comment.Role] {
|
||||||
|
return errf("unexpected colour for %s", comment.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "GET /api/docs/deploy-pipeline/stats aggregates by role", Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do("GET", "/api/docs/deploy-pipeline/stats", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ExpectStatus(response, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stats, err := Decode[storage.CommentStats](payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if stats.Total != 3 {
|
||||||
|
return errf("expected total 3, got %d", stats.Total)
|
||||||
|
}
|
||||||
|
for _, role := range []string{"user", "operator", "developer"} {
|
||||||
|
if stats.ByRole[role] != 1 {
|
||||||
|
return errf("expected 1 %s comment, got %d", role, stats.ByRole[role])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "the badge is derived server-side and cannot be spoofed by the client",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a documentation page", Do: func(c *Context) error {
|
||||||
|
_, err := c.Engine.SaveDoc("trust-boundary", "# Trust Boundary\n")
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: `a developer posts a comment claiming the user badge "🟢"`, Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do(
|
||||||
|
"POST", "/api/docs/trust-boundary/comments", "application/json",
|
||||||
|
`{"author":"mallory","role":"developer","content":"I am a user, honest","badge":"🟢"}`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ExpectStatus(response, 201); err != nil {
|
||||||
|
return errf("%w (body: %s)", err, payload)
|
||||||
|
}
|
||||||
|
c.Remember("payload", string(payload))
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the server overrides the claim with the developer badge", Do: func(c *Context) error {
|
||||||
|
comments, err := c.RawComments("trust-boundary")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(comments) != 1 {
|
||||||
|
return errf("expected 1 comment, got %d", len(comments))
|
||||||
|
}
|
||||||
|
return ExpectEqual(comments[0].Badge, "🔴", "server-assigned badge")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "an unknown role is rejected with a 400",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a documentation page", Do: func(c *Context) error {
|
||||||
|
_, err := c.Engine.SaveDoc("role-guard", "# Role Guard\n")
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: `a comment is posted with role "root"`, Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do(
|
||||||
|
"POST", "/api/docs/role-guard/comments", "application/json",
|
||||||
|
`{"author":"mallory","role":"root","content":"let me in"}`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Remember("status", response.StatusCode)
|
||||||
|
c.Remember("body", string(payload))
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the API answers 400 with the invalid_role code", Do: func(c *Context) error {
|
||||||
|
if err := ExpectEqual(c.RecallAs[int]("status"), 400, "status"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectContains(c.RecallAs[string]("body"), "invalid_role", "error body")
|
||||||
|
}},
|
||||||
|
{Text: "no feedback file is created", Do: func(c *Context) error {
|
||||||
|
exists, err := c.FileExists(c.CommentsPath("role-guard"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return errf("a feedback file was created for a rejected comment")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "an empty comment is rejected",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a documentation page", Do: func(c *Context) error {
|
||||||
|
_, err := c.Engine.SaveDoc("empty-guard", "# Empty Guard\n")
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: `a comment with blank content is posted`, Do: func(c *Context) error {
|
||||||
|
response, _, err := c.Do(
|
||||||
|
"POST", "/api/docs/empty-guard/comments", "application/json",
|
||||||
|
`{"author":"ana","role":"operator","content":" "}`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Remember("status", response.StatusCode)
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the API answers 400", Do: func(c *Context) error {
|
||||||
|
return ExpectEqual(c.RecallAs[int]("status"), 400, "status")
|
||||||
|
}},
|
||||||
|
{Text: "no feedback file is created", Do: func(c *Context) error {
|
||||||
|
exists, err := c.FileExists(c.CommentsPath("empty-guard"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return errf("a feedback file was created for a rejected comment")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "a comment on an unknown slug still creates the feedback file",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "no document exists for the slug new-service", Do: func(c *Context) error {
|
||||||
|
exists, err := c.FileExists(c.DocPath("new-service"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return errf("the document should not exist yet")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "the first user comment is posted for new-service", Do: func(c *Context) error {
|
||||||
|
response, _, err := c.Do(
|
||||||
|
"POST", "/api/docs/new-service/comments", "application/json",
|
||||||
|
`{"author":"jo","role":"user","content":"Is this documented?"}`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectStatus(response, 201)
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the feedback file holds the user badge 🟢", Do: func(c *Context) error {
|
||||||
|
comments, err := c.RawComments("new-service")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(comments) != 1 {
|
||||||
|
return errf("expected 1 comment, got %d", len(comments))
|
||||||
|
}
|
||||||
|
return ExpectEqual(comments[0].Badge, "🟢", "badge")
|
||||||
|
}},
|
||||||
|
{Text: "reading the comments of a slug with no feedback returns an empty list", Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do("GET", "/api/docs/never-seen/comments", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ExpectStatus(response, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listed, err := Decode[struct {
|
||||||
|
Comments []storage.Comment `json:"comments"`
|
||||||
|
}](payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectEqual(len(listed.Comments), 0, "comment count")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
285
backend/tests/bdd/framework.go
Normal file
285
backend/tests/bdd/framework.go
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
// Package bdd is a dependency-free BDD harness for the go-doc backend.
|
||||||
|
//
|
||||||
|
// Features are declared as Gherkin-shaped Given/When/Then scenarios and executed
|
||||||
|
// against the real storage engine and a real HTTP server backed by a temporary
|
||||||
|
// directory on disk. There are no mocks and no fakes: every Then step asserts on
|
||||||
|
// bytes that were actually written to the filesystem.
|
||||||
|
package bdd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-doc/router"
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step is a single Gherkin clause. Text is used verbatim as the subtest name, so
|
||||||
|
// a failing run reads as a sentence.
|
||||||
|
type Step struct {
|
||||||
|
Text string
|
||||||
|
Do func(c *Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) run(c *Context) error {
|
||||||
|
if err := s.Do(c); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", s.Text, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps is an ordered clause list.
|
||||||
|
type Steps []Step
|
||||||
|
|
||||||
|
func (s Steps) run(c *Context) error {
|
||||||
|
for _, step := range s {
|
||||||
|
if err := step.run(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario is one executable example.
|
||||||
|
type Scenario struct {
|
||||||
|
Name string
|
||||||
|
Given Steps
|
||||||
|
When Steps
|
||||||
|
Then Steps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature groups the scenarios for one business capability.
|
||||||
|
type Feature struct {
|
||||||
|
Name string
|
||||||
|
Scenarios []Scenario
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context carries the world a scenario runs against: a private data directory, a
|
||||||
|
// storage engine, and a live HTTP server. Steps share state through Remember and
|
||||||
|
// Recall so a When clause can hand data to a Then clause.
|
||||||
|
type Context struct {
|
||||||
|
T *testing.T
|
||||||
|
DataDir string
|
||||||
|
Engine *storage.Engine
|
||||||
|
Server *httptest.Server
|
||||||
|
Client *http.Client
|
||||||
|
|
||||||
|
// StartedAt timestamps scenario creation so load steps can assert a budget.
|
||||||
|
StartedAt time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
state map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContext provisions an isolated data directory and a live API server.
|
||||||
|
func NewContext(t *testing.T) *Context {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
engine, err := storage.New(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bdd: provision storage engine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, err := router.NewWithConfig(router.Config{
|
||||||
|
Engine: engine,
|
||||||
|
Logger: quietLogger(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bdd: build router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
return &Context{
|
||||||
|
T: t,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
DataDir: dataDir,
|
||||||
|
Engine: engine,
|
||||||
|
Server: server,
|
||||||
|
Client: server.Client(),
|
||||||
|
state: make(map[string]any),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember stores a value for later steps in the same scenario.
|
||||||
|
func (c *Context) Remember(key string, value any) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.state[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recall reads a remembered value, failing the step if it is absent or nil.
|
||||||
|
func (c *Context) Recall(key string) any {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
value, ok := c.state[key]
|
||||||
|
if !ok || value == nil {
|
||||||
|
c.T.Fatalf("bdd: scenario state has no value for %q", key)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecallAs is Recall with a type assertion.
|
||||||
|
func (c *Context) RecallAs[T any](key string) T {
|
||||||
|
value, ok := c.Recall(key).(T)
|
||||||
|
if !ok {
|
||||||
|
c.T.Fatalf("bdd: state %q is %T, not the requested type", key, c.Recall(key))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocPath is the on-disk location a slug must land on.
|
||||||
|
func (c *Context) DocPath(slug string) string {
|
||||||
|
return filepath.Join(c.DataDir, "docs", slug+".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentsPath is the on-disk location a slug's feedback must land on.
|
||||||
|
func (c *Context) CommentsPath(slug string) string {
|
||||||
|
return filepath.Join(c.DataDir, "comments", slug+"-comments.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawDoc reads the Markdown file straight off disk, bypassing the API.
|
||||||
|
func (c *Context) RawDoc(slug string) (string, error) {
|
||||||
|
data, err := os.ReadFile(c.DocPath(slug))
|
||||||
|
return string(data), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawComments decodes the JSON array straight off disk, bypassing the API.
|
||||||
|
func (c *Context) RawComments(slug string) ([]storage.Comment, error) {
|
||||||
|
data, err := os.ReadFile(c.CommentsPath(slug))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var comments []storage.Comment
|
||||||
|
if err := json.Unmarshal(data, &comments); err != nil {
|
||||||
|
return nil, fmt.Errorf("feedback file is not a valid JSON array: %w", err)
|
||||||
|
}
|
||||||
|
return comments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawJSON returns the undecoded bytes of the feedback file.
|
||||||
|
func (c *Context) RawJSON(slug string) ([]byte, error) {
|
||||||
|
return os.ReadFile(c.CommentsPath(slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do performs an HTTP request against the live server and returns the response
|
||||||
|
// together with its body.
|
||||||
|
func (c *Context) Do(method, path, contentType string, body string) (*http.Response, []byte, error) {
|
||||||
|
var reader io.Reader
|
||||||
|
if body != "" {
|
||||||
|
reader = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(method, c.Server.URL+path, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
request.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
response, err := c.Client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
payload, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return response, nil, err
|
||||||
|
}
|
||||||
|
return response, payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode unmarshals a JSON response body.
|
||||||
|
func Decode[T any](payload []byte) (T, error) {
|
||||||
|
var out T
|
||||||
|
err := json.Unmarshal(payload, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpectStatus fails the step unless the response carries the wanted status.
|
||||||
|
func ExpectStatus(response *http.Response, want int) error {
|
||||||
|
if response == nil {
|
||||||
|
return fmt.Errorf("no response")
|
||||||
|
}
|
||||||
|
if response.StatusCode != want {
|
||||||
|
return fmt.Errorf("expected HTTP %d, got %d", want, response.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpectContains fails the step unless haystack contains needle.
|
||||||
|
func ExpectContains(haystack, needle, label string) error {
|
||||||
|
if !strings.Contains(haystack, needle) {
|
||||||
|
return fmt.Errorf("%s: expected to contain %q, got %q", label, needle, haystack)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpectEqual fails the step unless got equals want.
|
||||||
|
func ExpectEqual[T comparable](got, want T, label string) error {
|
||||||
|
if got != want {
|
||||||
|
return fmt.Errorf("%s: expected %v, got %v", label, want, got)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileExists reports whether path is present on disk.
|
||||||
|
func (c *Context) FileExists(path string) (bool, error) {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a feature: every scenario gets a fresh Context, and every clause
|
||||||
|
// becomes its own subtest so failures point at the exact sentence that broke.
|
||||||
|
func Run(t *testing.T, feature Feature) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Run(feature.Name, func(t *testing.T) {
|
||||||
|
for _, scenario := range feature.Scenarios {
|
||||||
|
scenario := scenario
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
c := NewContext(t)
|
||||||
|
|
||||||
|
phase(t, "Given", c, scenario.Given)
|
||||||
|
phase(t, "When", c, scenario.When)
|
||||||
|
phase(t, "Then", c, scenario.Then)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func phase(t *testing.T, keyword string, c *Context, steps Steps) {
|
||||||
|
t.Helper()
|
||||||
|
for _, step := range steps {
|
||||||
|
t.Run(keyword+" "+step.Text, func(t *testing.T) {
|
||||||
|
if err := step.Do(c); err != nil {
|
||||||
|
t.Fatalf("%s %s\n%s", keyword, step.Text, indent(err.Error()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(text string) string {
|
||||||
|
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = " " + line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
61
backend/tests/bdd/helpers_test.go
Normal file
61
backend/tests/bdd/helpers_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package bdd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func errf(format string, args ...any) error {
|
||||||
|
return fmt.Errorf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge assertions shared by the feedback features, keyed to the role -> badge
|
||||||
|
// contract mandated by the specification: User 🟢, Operator 🔵, Developer 🔴.
|
||||||
|
var badgeContract = map[storage.Role]string{
|
||||||
|
storage.RoleUser: "🟢",
|
||||||
|
storage.RoleOperator: "🔵",
|
||||||
|
storage.RoleDeveloper: "🔴",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex colours the frontend mirrors when it renders the badge.
|
||||||
|
var badgeHex = map[storage.Role]string{
|
||||||
|
storage.RoleUser: "#22c55e",
|
||||||
|
storage.RoleOperator: "#3b82f6",
|
||||||
|
storage.RoleDeveloper: "#ef4444",
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONArrayPrefix is the first byte of the on-disk feedback file. The spec
|
||||||
|
// requires the file to *be* the JSON array, so every write starts with "[".
|
||||||
|
const JSONArrayPrefix = `[`
|
||||||
|
|
||||||
|
func appendComment(c *Context, slug string, role storage.Role, author, content string) (storage.Comment, error) {
|
||||||
|
return c.Engine.AppendComment(slug, storage.CommentInput{
|
||||||
|
Author: author,
|
||||||
|
Role: string(role),
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// listDir returns the entry names of a directory, for temp-file leak checks.
|
||||||
|
func listDir(dir string) ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
names = append(names, entry.Name())
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate shortens a document for readable failure output.
|
||||||
|
func truncate(text string) string {
|
||||||
|
const limit = 120
|
||||||
|
if len(text) <= limit {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return text[:limit] + "…"
|
||||||
|
}
|
||||||
417
backend/tests/bdd/load.feature_test.go
Normal file
417
backend/tests/bdd/load.feature_test.go
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
package bdd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFeatureFileUpdatesUnderLoad covers:
|
||||||
|
//
|
||||||
|
// Given many documents and thousands of comments in flight
|
||||||
|
// When they are written concurrently through the API
|
||||||
|
// Then every update lands on disk with no torn or lost writes
|
||||||
|
func TestFeatureFileUpdatesUnderLoad(t *testing.T) {
|
||||||
|
Run(t, Feature{
|
||||||
|
Name: "File updates under load",
|
||||||
|
Scenarios: []Scenario{
|
||||||
|
{
|
||||||
|
Name: "concurrent comment injection loses no entry",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a documentation page with 400 concurrent feedback writers", Do: func(c *Context) error {
|
||||||
|
_, err := c.Engine.SaveDoc("hot-service", "# Hot Service\n\n## For Operators\n\nScaling notes.\n")
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "400 comments are appended concurrently across all three roles", Do: func(c *Context) error {
|
||||||
|
const writers = 400
|
||||||
|
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, writers)
|
||||||
|
start := make(chan struct{})
|
||||||
|
|
||||||
|
for i := 0; i < writers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start // release every writer at once
|
||||||
|
role := roles[index%len(roles)]
|
||||||
|
if _, err := appendComment(c, "hot-service", role,
|
||||||
|
fmt.Sprintf("user-%03d", index),
|
||||||
|
fmt.Sprintf("finding number %d", index),
|
||||||
|
); err != nil {
|
||||||
|
errs <- err
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
for err := range errs {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "all 400 comments are present in the JSON array", Do: func(c *Context) error {
|
||||||
|
comments, err := c.RawComments("hot-service")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(comments) != 400 {
|
||||||
|
return errf("expected 400 persisted comments, got %d", len(comments))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "no comment was lost to a read-modify-write race", Do: func(c *Context) error {
|
||||||
|
comments, err := c.RawComments("hot-service")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(comments))
|
||||||
|
for _, comment := range comments {
|
||||||
|
key := fmt.Sprintf("%s|%s", comment.Author, comment.Content)
|
||||||
|
if _, duplicate := seen[key]; duplicate {
|
||||||
|
return errf("duplicate entry survived: %s", key)
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(seen) != 400 {
|
||||||
|
return errf("expected 400 distinct entries, got %d", len(seen))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "every comment carries the badge for its own role", Do: func(c *Context) error {
|
||||||
|
comments, err := c.RawComments("hot-service")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, comment := range comments {
|
||||||
|
if comment.Badge != badgeContract[comment.Role] {
|
||||||
|
return errf("comment %s has badge %q, want %q",
|
||||||
|
comment.ID, comment.Badge, badgeContract[comment.Role])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "the file on disk is a single valid JSON array, never a torn write", Do: func(c *Context) error {
|
||||||
|
raw, err := c.RawJSON("hot-service")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") ||
|
||||||
|
!strings.HasSuffix(strings.TrimSpace(string(raw)), "]") {
|
||||||
|
return errf("feedback file is not a well-formed JSON array")
|
||||||
|
}
|
||||||
|
var decoded []storage.Comment
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
return errf("feedback file failed to decode: %w", err)
|
||||||
|
}
|
||||||
|
return ExpectEqual(len(decoded), 400, "decoded comment count")
|
||||||
|
}},
|
||||||
|
{Text: "the HTTP read path agrees with the file on disk", Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do("GET", "/api/docs/hot-service/comments", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ExpectStatus(response, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listed, err := Decode[struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}](payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectEqual(listed.Count, 400, "API comment count")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "readers never observe a partially written document while writers stream updates",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a document with a long body", Do: func(c *Context) error {
|
||||||
|
body := strings.Repeat("Sentinel paragraph for torn-read detection.\n\n", 400)
|
||||||
|
_, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body)
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "20 writers and 40 readers hammer the API concurrently", Do: func(c *Context) error {
|
||||||
|
const (
|
||||||
|
writers = 20
|
||||||
|
readers = 40
|
||||||
|
rounds = 25
|
||||||
|
)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, writers+readers)
|
||||||
|
|
||||||
|
for w := 0; w < writers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for round := 0; round < rounds; round++ {
|
||||||
|
body := strings.Repeat(
|
||||||
|
fmt.Sprintf("writer %d round %d payload line.\n\n", index, round), 200)
|
||||||
|
if _, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body); err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
for r := 0; r < readers; r++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for round := 0; round < rounds*4; round++ {
|
||||||
|
content, err := c.Engine.ReadDoc("live-doc")
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(content, "# Live Doc\n\n") {
|
||||||
|
errs <- errf("reader saw a truncated document header: %q", truncate(content))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "every read returned a complete document", Do: func(c *Context) error {
|
||||||
|
content, err := c.RawDoc("live-doc")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(content, "# Live Doc\n\n") {
|
||||||
|
return errf("final document is malformed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "no temporary file was left behind in data/docs", Do: func(c *Context) error {
|
||||||
|
entries, err := listDir(c.DataDir + "/docs")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, name := range entries {
|
||||||
|
if strings.HasPrefix(name, ".godoc-tmp-") {
|
||||||
|
return errf("atomic-write temp file leaked: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "many documents can be seeded in parallel without cross-contamination",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "120 documents seeded across 8 workers with 25 comments each", Do: func(c *Context) error {
|
||||||
|
const (
|
||||||
|
docs = 120
|
||||||
|
perDoc = 25
|
||||||
|
numWorkers = 8
|
||||||
|
)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, numWorkers)
|
||||||
|
jobs := make(chan int, docs)
|
||||||
|
|
||||||
|
for i := 0; i < docs; i++ {
|
||||||
|
jobs <- i
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
|
||||||
|
for w := 0; w < numWorkers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(worker int) {
|
||||||
|
defer wg.Done()
|
||||||
|
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||||||
|
for index := range jobs {
|
||||||
|
title := fmt.Sprintf("Service Suite %03d Overview", index)
|
||||||
|
slug := storage.Slugify(title)
|
||||||
|
body := fmt.Sprintf("# %s\n\n## For Users\n\nUsage.\n\n## For Operators\n\nRunbook.\n\n## For Developers\n\nAPI.\n",
|
||||||
|
title)
|
||||||
|
if _, err := c.Engine.SaveDoc(title, body); err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for n := 0; n < perDoc; n++ {
|
||||||
|
role := roles[n%len(roles)]
|
||||||
|
if _, err := c.Engine.AppendComment(slug, storage.CommentInput{
|
||||||
|
Author: fmt.Sprintf("w%d-u%d", worker, n),
|
||||||
|
Role: string(role),
|
||||||
|
Content: fmt.Sprintf("note %d for %s", n, slug),
|
||||||
|
}); err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "the document index is read back", Do: func(c *Context) error {
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "all 120 slugified documents are indexed", Do: func(c *Context) error {
|
||||||
|
metas, err := c.Engine.ListDocs()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectEqual(len(metas), 120, "indexed document count")
|
||||||
|
}},
|
||||||
|
{Text: "each document owns exactly its own 25 comments", Do: func(c *Context) error {
|
||||||
|
for index := 0; index < 120; index++ {
|
||||||
|
slug := storage.Slugify(fmt.Sprintf("Service Suite %03d Overview", index))
|
||||||
|
comments, err := c.RawComments(slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(comments) != 25 {
|
||||||
|
return errf("%s: expected 25 comments, got %d", slug, len(comments))
|
||||||
|
}
|
||||||
|
for _, comment := range comments {
|
||||||
|
if !strings.Contains(comment.Content, slug) {
|
||||||
|
return errf("%s: comment from another document leaked in: %q", slug, comment.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "the aggregate stats total 3000 comments", Do: func(c *Context) error {
|
||||||
|
response, payload, err := c.Do("GET", "/api/stats", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ExpectStatus(response, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stats, err := Decode[struct {
|
||||||
|
Docs int `json:"docs"`
|
||||||
|
Comments int `json:"comments"`
|
||||||
|
}](payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if stats.Docs != 120 {
|
||||||
|
return errf("expected 120 docs, got %d", stats.Docs)
|
||||||
|
}
|
||||||
|
return ExpectEqual(stats.Comments, 3000, "aggregate comment count")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "a burst of HTTP comment posts is durable",
|
||||||
|
Given: Steps{
|
||||||
|
{Text: "a documentation page served over HTTP", Do: func(c *Context) error {
|
||||||
|
response, _, err := c.Do("POST", "/api/docs/http-burst", "text/markdown", "# HTTP Burst\n")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ExpectStatus(response, 201)
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
When: Steps{
|
||||||
|
{Text: "200 concurrent POSTs hit /api/docs/http-burst/comments", Do: func(c *Context) error {
|
||||||
|
const posts = 200
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, posts)
|
||||||
|
|
||||||
|
for i := 0; i < posts; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wg.Done()
|
||||||
|
role := []string{"user", "operator", "developer"}[index%3]
|
||||||
|
body := fmt.Sprintf(`{"author":"burst-%03d","role":"%s","content":"burst comment %d"}`, index, role, index)
|
||||||
|
response, payload, err := c.Do("POST", "/api/docs/http-burst/comments", "application/json", body)
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if response.StatusCode != 201 {
|
||||||
|
errs <- errf("POST %d returned %d: %s", index, response.StatusCode, payload)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Then: Steps{
|
||||||
|
{Text: "the feedback file holds all 200 posts", Do: func(c *Context) error {
|
||||||
|
comments, err := c.RawComments("http-burst")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(comments) != 200 {
|
||||||
|
return errf("expected 200 persisted comments, got %d", len(comments))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "every comment has a unique ID and a role badge", Do: func(c *Context) error {
|
||||||
|
comments, err := c.RawComments("http-burst")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids := make(map[string]struct{}, len(comments))
|
||||||
|
for _, comment := range comments {
|
||||||
|
if _, duplicate := ids[comment.ID]; duplicate {
|
||||||
|
return errf("duplicate comment ID %s", comment.ID)
|
||||||
|
}
|
||||||
|
ids[comment.ID] = struct{}{}
|
||||||
|
if comment.Badge == "" {
|
||||||
|
return errf("comment %s has no badge", comment.ID)
|
||||||
|
}
|
||||||
|
if comment.Timestamp.IsZero() {
|
||||||
|
return errf("comment %s has no timestamp", comment.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Text: "the write completes well inside a test timeout", Do: func(c *Context) error {
|
||||||
|
// Guards against a regression to unbounded fsync storms: the whole
|
||||||
|
// scenario must finish inside the 10 minute suite budget.
|
||||||
|
if elapsed := time.Since(c.StartedAt); elapsed > 2*time.Minute {
|
||||||
|
return errf("burst scenario took %s", elapsed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
12
backend/tests/bdd/logger.go
Normal file
12
backend/tests/bdd/logger.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package bdd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// quietLogger keeps the BDD output readable: the harness reports failures itself,
|
||||||
|
// so the router's per-request access log is discarded.
|
||||||
|
func quietLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
220
backend/tests/docgen_import_test.go
Normal file
220
backend/tests/docgen_import_test.go
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
// This file holds the end-to-end suite for the documentation importer: analyse a
|
||||||
|
// real project tree on disk, render a page, merge it through the storage engine,
|
||||||
|
// and read it back over HTTP. No mocks anywhere -- if the pipeline breaks, these
|
||||||
|
// tests break.
|
||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-doc/docgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getJSON fetches a resource as JSON, which is how the frontend reads pages.
|
||||||
|
// The API negotiates with ?format=json rather than the Accept header.
|
||||||
|
func (h *harness) getJSON(path string) map[string]any {
|
||||||
|
h.t.Helper()
|
||||||
|
if !strings.Contains(path, "?") {
|
||||||
|
path += "?format=json"
|
||||||
|
}
|
||||||
|
payload := h.expectStatus(http.MethodGet, path, "", "", http.StatusOK)
|
||||||
|
return decodeJSON[map[string]any](h.t, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationDocgenImport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
workspace := t.TempDir()
|
||||||
|
writeFixtureProject(t, filepath.Join(workspace, "go-fixture"))
|
||||||
|
|
||||||
|
project, err := docgen.Analyze("go-fixture", filepath.Join(workspace, "go-fixture"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("integration: analyse fixture: %v", err)
|
||||||
|
}
|
||||||
|
page, err := docgen.Render(project)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("integration: render fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slug, err := h.engine.SaveDoc(project.Name, page)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("integration: import page: %v", err)
|
||||||
|
}
|
||||||
|
if slug != "go-fixture" {
|
||||||
|
t.Fatalf("integration: expected slug go-fixture, got %q", slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page lands on disk exactly once, wrapped in generated markers that
|
||||||
|
// record which project it came from.
|
||||||
|
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "go-fixture.md")))
|
||||||
|
if !strings.HasPrefix(onDisk, "<!-- go-doc:generated:begin") {
|
||||||
|
t.Errorf("imported page is missing its opening marker:\n%s", firstLineOf(onDisk))
|
||||||
|
}
|
||||||
|
if !strings.Contains(onDisk, "source=go-fixture") {
|
||||||
|
t.Errorf("imported page does not record its source project:\n%s", firstLineOf(onDisk))
|
||||||
|
}
|
||||||
|
if strings.Count(onDisk, "go-doc:generated:end") != 1 {
|
||||||
|
t.Errorf("imported page has %d closing markers, want 1",
|
||||||
|
strings.Count(onDisk, "go-doc:generated:end"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API serves it under the title the index shows.
|
||||||
|
doc := h.getJSON("/api/docs/go-fixture")
|
||||||
|
if doc["title"] != "go-fixture" {
|
||||||
|
t.Errorf("title = %v, want go-fixture", doc["title"])
|
||||||
|
}
|
||||||
|
content, _ := doc["content"].(string)
|
||||||
|
if content != onDisk {
|
||||||
|
t.Error("API content differs from the file on disk")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every audience tab the reader keys off is present, in order.
|
||||||
|
users := strings.Index(content, "## For Users")
|
||||||
|
operators := strings.Index(content, "## For Operators")
|
||||||
|
developers := strings.Index(content, "## For Developers")
|
||||||
|
if users < 0 || operators < 0 || developers < 0 {
|
||||||
|
t.Fatalf("audience sections missing: %d %d %d", users, operators, developers)
|
||||||
|
}
|
||||||
|
if !(users < operators && operators < developers) {
|
||||||
|
t.Errorf("audience sections are out of order: %d %d %d", users, operators, developers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facts extracted from the source reached the page.
|
||||||
|
for _, want := range []string{"| `GET` | `/healthz` | `health` |", "`DEMO_HTTP_PORT`", "`make build`"} {
|
||||||
|
if !strings.Contains(content, want) {
|
||||||
|
t.Errorf("imported page is missing extracted fact %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// It appears in the index.
|
||||||
|
index := h.getJSON("/api/docs")
|
||||||
|
docs, _ := index["docs"].([]any)
|
||||||
|
if len(docs) != 1 {
|
||||||
|
t.Fatalf("expected 1 indexed document, got %d", len(docs))
|
||||||
|
}
|
||||||
|
entry, _ := docs[0].(map[string]any)
|
||||||
|
if entry["slug"] != "go-fixture" {
|
||||||
|
t.Errorf("index slug = %v, want go-fixture", entry["slug"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A comment attaches to a generated page like any other document.
|
||||||
|
h.expectStatus(http.MethodPost, "/api/docs/go-fixture/comments",
|
||||||
|
"application/json",
|
||||||
|
`{"role":"operator","author":"sre","content":"The port table needs the compose port too."}`,
|
||||||
|
http.StatusCreated)
|
||||||
|
stats := h.getJSON("/api/docs/go-fixture/stats")
|
||||||
|
if stats["total"] != float64(1) {
|
||||||
|
t.Errorf("comment count = %v, want 1", stats["total"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-importing identical content changes nothing, and prose a human added
|
||||||
|
// outside the markers survives the sync.
|
||||||
|
pagePath := filepath.Join(h.docsDir, "go-fixture.md")
|
||||||
|
withFooter := onDisk + "\n## Notes from the team\n\nCheck the runbook before deploying.\n"
|
||||||
|
if err := os.WriteFile(pagePath, []byte(withFooter), 0o644); err != nil {
|
||||||
|
t.Fatalf("integration: write page: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, status, err := docgen.Merge(withFooter, page, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("integration: re-merge: %v", err)
|
||||||
|
}
|
||||||
|
if status != docgen.StatusUnchanged {
|
||||||
|
t.Errorf("re-importing an unchanged project reported %q, want unchanged", status)
|
||||||
|
}
|
||||||
|
if _, err := h.engine.SaveDoc(project.Name, merged); err != nil {
|
||||||
|
t.Fatalf("integration: re-import: %v", err)
|
||||||
|
}
|
||||||
|
if reread := string(requireFile(t, pagePath)); !strings.Contains(reread, "Check the runbook before deploying.") {
|
||||||
|
t.Error("re-import dropped hand-written prose")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationDocgenProtectsHandWrittenPages proves the importer never
|
||||||
|
// overwrites a document that it did not generate.
|
||||||
|
func TestIntegrationDocgenProtectsHandWrittenPages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
const handWritten = "# go-doc\n\nWritten by a person, with no generated markers.\n"
|
||||||
|
if _, err := h.engine.SaveDoc("go-doc", handWritten); err != nil {
|
||||||
|
t.Fatalf("integration: seed hand-written page: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
generated, err := docgen.Render(&docgen.Project{Name: "go-doc", Summary: "Machine written."})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("integration: render: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pagePath := filepath.Join(h.docsDir, "go-doc.md")
|
||||||
|
existing := string(requireFile(t, pagePath))
|
||||||
|
|
||||||
|
merged, status, err := docgen.Merge(existing, generated, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("integration: merge: %v", err)
|
||||||
|
}
|
||||||
|
if status != docgen.StatusSkipped {
|
||||||
|
t.Errorf("merge status = %q, want skipped for an unmarked page", status)
|
||||||
|
}
|
||||||
|
if merged != existing {
|
||||||
|
t.Error("a hand-written page was modified by the importer")
|
||||||
|
}
|
||||||
|
if _, err := h.engine.SaveDoc("go-doc", merged); err != nil {
|
||||||
|
t.Fatalf("integration: save: %v", err)
|
||||||
|
}
|
||||||
|
if reread := string(requireFile(t, pagePath)); reread != handWritten {
|
||||||
|
t.Errorf("hand-written page changed on disk:\n%s", reread)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationDocgenMissingProjectIsReportedNotIgnored covers the case the
|
||||||
|
// importer must never paper over: a project directory that cannot be analysed.
|
||||||
|
func TestIntegrationDocgenMissingProjectIsReportedNotIgnored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
workspace := t.TempDir()
|
||||||
|
if _, err := docgen.Analyze("go-absent", filepath.Join(workspace, "go-absent")); err == nil {
|
||||||
|
t.Fatal("analysing a missing project should fail loudly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFixtureProject lays out a small project exercising the extractors the
|
||||||
|
// importer depends on: routes, flags, environment, a module, a Makefile and a
|
||||||
|
// README.
|
||||||
|
func writeFixtureProject(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
files := map[string]string{
|
||||||
|
"README.md": "# Demo Service\n\nDemo answers questions and exposes a JSON API.\n",
|
||||||
|
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire github.com/go-chi/chi/v5 v5.2.3\n",
|
||||||
|
"Makefile": "build:\n\tgo build ./...\n",
|
||||||
|
".env.example": "DEMO_HTTP_PORT=8080\n",
|
||||||
|
"internal/server/routes.go": "package server\n\n" +
|
||||||
|
"import \"net/http\"\n\n" +
|
||||||
|
"func Routes(mux *http.ServeMux) {\n" +
|
||||||
|
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
|
||||||
|
"\tmux.HandleFunc(\"/api/items\", listItems)\n}\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("integration: mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("integration: write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLineOf(s string) string {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
842
backend/tests/integration_test.go
Normal file
842
backend/tests/integration_test.go
Normal file
|
|
@ -0,0 +1,842 @@
|
||||||
|
// Package tests contains the end-to-end API suite.
|
||||||
|
//
|
||||||
|
// These are true integration tests: every request travels over HTTP to a live
|
||||||
|
// server, every assertion inspects the real filesystem. There are no mocks, no
|
||||||
|
// fakes and no in-memory doubles anywhere in this file -- if the flat-file engine
|
||||||
|
// breaks, these tests break.
|
||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-doc/router"
|
||||||
|
"go-doc/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// harness wires a real storage engine to a real HTTP server over a temporary
|
||||||
|
// data directory.
|
||||||
|
type harness struct {
|
||||||
|
t *testing.T
|
||||||
|
dataDir string
|
||||||
|
engine *storage.Engine
|
||||||
|
server *httptest.Server
|
||||||
|
client *http.Client
|
||||||
|
docsDir string
|
||||||
|
notePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHarness(t *testing.T) *harness {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
engine, err := storage.New(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("integration: provision engine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, err := router.NewWithConfig(router.Config{
|
||||||
|
Engine: engine,
|
||||||
|
Logger: discardLogger(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("integration: build router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
return &harness{
|
||||||
|
t: t,
|
||||||
|
dataDir: dataDir,
|
||||||
|
engine: engine,
|
||||||
|
server: server,
|
||||||
|
client: server.Client(),
|
||||||
|
docsDir: filepath.Join(dataDir, "docs"),
|
||||||
|
notePath: filepath.Join(dataDir, "comments"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// request performs an HTTP call and returns the response with its body.
|
||||||
|
func (h *harness) request(method, path, contentType, body string) (*http.Response, []byte) {
|
||||||
|
h.t.Helper()
|
||||||
|
|
||||||
|
var reader io.Reader
|
||||||
|
if body != "" {
|
||||||
|
reader = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, h.server.URL+path, reader)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("integration: build %s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("integration: %s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
payload, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("integration: read body of %s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
return resp, payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *harness) expectStatus(method, path, contentType, body string, want int) []byte {
|
||||||
|
h.t.Helper()
|
||||||
|
resp, payload := h.request(method, path, contentType, body)
|
||||||
|
if resp.StatusCode != want {
|
||||||
|
h.t.Fatalf("%s %s: expected HTTP %d, got %d (body: %s)", method, path, want, resp.StatusCode, payload)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON[T any](t *testing.T, payload []byte) T {
|
||||||
|
t.Helper()
|
||||||
|
var out T
|
||||||
|
if err := json.Unmarshal(payload, &out); err != nil {
|
||||||
|
t.Fatalf("integration: decode %s: %v", payload, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireFile asserts a file exists on the real filesystem and returns its bytes.
|
||||||
|
func requireFile(t *testing.T, path string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("integration: expected %s to exist: %v", path, err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireNoFile(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
t.Fatalf("integration: expected %s to be absent", path)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("integration: stat %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestIntegrationDocumentLifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationDocumentLifecycle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
t.Run("health reports the live data root", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/health", "", "", http.StatusOK)
|
||||||
|
health := decodeJSON[struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
DataRoot string `json:"data_root"`
|
||||||
|
}](t, payload)
|
||||||
|
|
||||||
|
if health.Status != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %q", health.Status)
|
||||||
|
}
|
||||||
|
if health.DataRoot != h.dataDir {
|
||||||
|
t.Fatalf("expected data root %q, got %q", h.dataDir, health.DataRoot)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST creates a slugified Markdown file on disk", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus(
|
||||||
|
"POST", "/api/docs/Core%20Service%20API", "text/markdown",
|
||||||
|
"# Core Service API\n\nBilling and invoicing endpoints.\n",
|
||||||
|
http.StatusCreated,
|
||||||
|
)
|
||||||
|
saved := decodeJSON[struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Created bool `json:"created"`
|
||||||
|
}](t, payload)
|
||||||
|
|
||||||
|
if saved.Slug != "core-service-api" {
|
||||||
|
t.Fatalf("expected slug core-service-api, got %q", saved.Slug)
|
||||||
|
}
|
||||||
|
if !saved.Created {
|
||||||
|
t.Fatal("expected created=true on first write")
|
||||||
|
}
|
||||||
|
if saved.Title != "Core Service API" {
|
||||||
|
t.Fatalf("expected the H1 to become the title, got %q", saved.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisk := requireFile(t, filepath.Join(h.docsDir, "core-service-api.md"))
|
||||||
|
if !bytes.Contains(onDisk, []byte("Billing and invoicing endpoints.")) {
|
||||||
|
t.Fatalf("file content mismatch: %s", onDisk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET returns the raw Markdown with the right content type", func(t *testing.T) {
|
||||||
|
resp, payload := h.request("GET", "/api/docs/core-service-api", "", "")
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
|
||||||
|
t.Fatalf("expected text/markdown, got %q", ct)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(string(payload), "# Core Service API") {
|
||||||
|
t.Fatalf("unexpected markdown body: %s", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET ?format=json returns the document envelope", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/docs/core-service-api?format=json", "", "", http.StatusOK)
|
||||||
|
doc := decodeJSON[struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}](t, payload)
|
||||||
|
|
||||||
|
if doc.Slug != "core-service-api" || doc.Title != "Core Service API" {
|
||||||
|
t.Fatalf("unexpected envelope: %+v", doc)
|
||||||
|
}
|
||||||
|
if doc.Size <= 0 {
|
||||||
|
t.Fatalf("expected a positive size, got %d", doc.Size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST with a JSON body updates the file in place", func(t *testing.T) {
|
||||||
|
h.expectStatus(
|
||||||
|
"POST", "/api/docs/core-service-api", "application/json",
|
||||||
|
`{"title":"Core Service API v2","content":"# Core Service API v2\n\nRewritten.\n"}`,
|
||||||
|
http.StatusOK,
|
||||||
|
)
|
||||||
|
|
||||||
|
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "core-service-api.md")))
|
||||||
|
if strings.Contains(onDisk, "Billing and invoicing") {
|
||||||
|
t.Fatalf("update did not replace the content wholesale: %s", onDisk)
|
||||||
|
}
|
||||||
|
if !strings.Contains(onDisk, "# Core Service API v2") {
|
||||||
|
t.Fatalf("update missing from disk: %s", onDisk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/docs indexes the document", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/docs", "", "", http.StatusOK)
|
||||||
|
index := decodeJSON[struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Docs []storage.DocMeta `json:"docs"`
|
||||||
|
}](t, payload)
|
||||||
|
|
||||||
|
if index.Count != 1 || index.Docs[0].Slug != "core-service-api" {
|
||||||
|
t.Fatalf("unexpected index: %+v", index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("search filters the index", func(t *testing.T) {
|
||||||
|
h.expectStatus("POST", "/api/docs/Payments%20Gateway", "text/markdown", "# Payments Gateway\n", http.StatusCreated)
|
||||||
|
|
||||||
|
payload := h.expectStatus("GET", "/api/docs?q=payment", "", "", http.StatusOK)
|
||||||
|
index := decodeJSON[struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}](t, payload)
|
||||||
|
if index.Count != 1 {
|
||||||
|
t.Fatalf("expected exactly one search hit, got %d", index.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = h.expectStatus("GET", "/api/docs?q=nothing-matches", "", "", http.StatusOK)
|
||||||
|
index = decodeJSON[struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}](t, payload)
|
||||||
|
if index.Count != 0 {
|
||||||
|
t.Fatalf("expected no hits, got %d", index.Count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DELETE removes the file and cascades the feedback", func(t *testing.T) {
|
||||||
|
h.expectStatus("POST", "/api/docs/core-service-api/comments", "application/json",
|
||||||
|
`{"author":"ana","role":"developer","content":"delete me"}`, http.StatusCreated)
|
||||||
|
requireFile(t, filepath.Join(h.notePath, "core-service-api-comments.json"))
|
||||||
|
|
||||||
|
h.expectStatus("DELETE", "/api/docs/core-service-api", "", "", http.StatusNoContent)
|
||||||
|
requireNoFile(t, filepath.Join(h.docsDir, "core-service-api.md"))
|
||||||
|
requireNoFile(t, filepath.Join(h.notePath, "core-service-api-comments.json"))
|
||||||
|
|
||||||
|
h.expectStatus("GET", "/api/docs/core-service-api", "", "", http.StatusNotFound)
|
||||||
|
h.expectStatus("DELETE", "/api/docs/core-service-api", "", "", http.StatusNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestIntegrationCommentTracking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationCommentTracking(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
h.expectStatus("POST", "/api/docs/Deploy%20Pipeline", "text/markdown", "# Deploy Pipeline\n", http.StatusCreated)
|
||||||
|
|
||||||
|
type created struct {
|
||||||
|
Comment storage.Comment `json:"comment"`
|
||||||
|
Badge string `json:"badge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
role string
|
||||||
|
author string
|
||||||
|
content string
|
||||||
|
badge string
|
||||||
|
}{
|
||||||
|
{"user", "sam", "The wizard confused me", "🟢"},
|
||||||
|
{"Operator", "robin", "Rollback takes 20 minutes", "🔵"},
|
||||||
|
{"DEVELOPER", "kit", "Add a --dry-run flag", "🔴"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run("role "+tc.role+" receives badge "+tc.badge, func(t *testing.T) {
|
||||||
|
body := fmt.Sprintf(`{"author":%q,"role":%q,"content":%q}`, tc.author, tc.role, tc.content)
|
||||||
|
payload := h.expectStatus("POST", "/api/docs/deploy-pipeline/comments", "application/json", body, http.StatusCreated)
|
||||||
|
result := decodeJSON[created](t, payload)
|
||||||
|
|
||||||
|
if result.Badge != tc.badge {
|
||||||
|
t.Fatalf("expected badge %q, got %q", tc.badge, result.Badge)
|
||||||
|
}
|
||||||
|
if result.Comment.Badge != tc.badge {
|
||||||
|
t.Fatalf("expected the stored badge %q, got %q", tc.badge, result.Comment.Badge)
|
||||||
|
}
|
||||||
|
if result.Comment.ID == "" {
|
||||||
|
t.Fatal("expected a generated comment ID")
|
||||||
|
}
|
||||||
|
if result.Comment.Timestamp.IsZero() {
|
||||||
|
t.Fatal("expected a timestamp")
|
||||||
|
}
|
||||||
|
if string(result.Comment.Role) != strings.ToLower(tc.role) {
|
||||||
|
t.Fatalf("expected the canonical role, got %q", result.Comment.Role)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("the feedback file is a JSON array in insertion order", func(t *testing.T) {
|
||||||
|
raw := requireFile(t, filepath.Join(h.notePath, "deploy-pipeline-comments.json"))
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
|
||||||
|
t.Fatalf("expected a JSON array, got: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored []storage.Comment
|
||||||
|
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||||
|
t.Fatalf("feedback file is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(stored) != 3 {
|
||||||
|
t.Fatalf("expected 3 comments, got %d", len(stored))
|
||||||
|
}
|
||||||
|
for i, want := range []struct{ role, badge string }{
|
||||||
|
{"user", "🟢"},
|
||||||
|
{"operator", "🔵"},
|
||||||
|
{"developer", "🔴"},
|
||||||
|
} {
|
||||||
|
if string(stored[i].Role) != want.role || stored[i].Badge != want.badge {
|
||||||
|
t.Fatalf("comment %d: got %s/%s, want %s/%s", i, stored[i].Role, stored[i].Badge, want.role, want.badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET lists the comments with their badges", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK)
|
||||||
|
listed := decodeJSON[struct {
|
||||||
|
Comments []storage.Comment `json:"comments"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}](t, payload)
|
||||||
|
|
||||||
|
if listed.Count != 3 || len(listed.Comments) != 3 {
|
||||||
|
t.Fatalf("expected 3 comments, got %d", listed.Count)
|
||||||
|
}
|
||||||
|
if listed.Comments[2].Badge != "🔴" {
|
||||||
|
t.Fatalf("expected the developer badge, got %q", listed.Comments[2].Badge)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DELETE removes a single comment by ID", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK)
|
||||||
|
listed := decodeJSON[struct {
|
||||||
|
Comments []storage.Comment `json:"comments"`
|
||||||
|
}](t, payload)
|
||||||
|
|
||||||
|
target := listed.Comments[0].ID
|
||||||
|
h.expectStatus("DELETE", "/api/docs/deploy-pipeline/comments/"+target, "", "", http.StatusNoContent)
|
||||||
|
h.expectStatus("DELETE", "/api/docs/deploy-pipeline/comments/"+target, "", "", http.StatusNotFound)
|
||||||
|
|
||||||
|
remaining := decodeJSON[struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}](t, h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK))
|
||||||
|
if remaining.Count != 2 {
|
||||||
|
t.Fatalf("expected 2 remaining comments, got %d", remaining.Count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stats aggregate by role", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/stats", "", "", http.StatusOK)
|
||||||
|
stats := decodeJSON[storage.CommentStats](t, payload)
|
||||||
|
|
||||||
|
if stats.Total != 2 {
|
||||||
|
t.Fatalf("expected total 2, got %d", stats.Total)
|
||||||
|
}
|
||||||
|
if stats.ByRole["operator"] != 1 || stats.ByRole["developer"] != 1 {
|
||||||
|
t.Fatalf("unexpected role tally: %+v", stats.ByRole)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the role catalogue is exposed for the UI", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/roles", "", "", http.StatusOK)
|
||||||
|
catalogue := decodeJSON[struct {
|
||||||
|
Roles []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Badge string `json:"badge"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Hex string `json:"hex"`
|
||||||
|
} `json:"roles"`
|
||||||
|
}](t, payload)
|
||||||
|
|
||||||
|
want := map[string][3]string{
|
||||||
|
"user": {"🟢", "User", "#22c55e"},
|
||||||
|
"operator": {"🔵", "Operator", "#3b82f6"},
|
||||||
|
"developer": {"🔴", "Developer", "#ef4444"},
|
||||||
|
}
|
||||||
|
if len(catalogue.Roles) != 3 {
|
||||||
|
t.Fatalf("expected 3 roles, got %d", len(catalogue.Roles))
|
||||||
|
}
|
||||||
|
for _, role := range catalogue.Roles {
|
||||||
|
expected, ok := want[role.Role]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected role %q", role.Role)
|
||||||
|
}
|
||||||
|
if role.Badge != expected[0] || role.Label != expected[1] || role.Hex != expected[2] {
|
||||||
|
t.Fatalf("role %s mismatch: %+v", role.Role, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestIntegrationValidationAndErrors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationValidationAndErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
t.Run("unknown document returns a structured 404", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/docs/does-not-exist", "", "", http.StatusNotFound)
|
||||||
|
body := decodeJSON[struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}](t, payload)
|
||||||
|
|
||||||
|
if body.Error.Code != "not_found" || body.Status != http.StatusNotFound {
|
||||||
|
t.Fatalf("unexpected error body: %s", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid role returns 400 invalid_role", func(t *testing.T) {
|
||||||
|
h.expectStatus("POST", "/api/docs/guarded", "text/markdown", "# Guarded\n", http.StatusCreated)
|
||||||
|
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json",
|
||||||
|
`{"author":"mallory","role":"root","content":"escalate"}`, http.StatusBadRequest)
|
||||||
|
|
||||||
|
if !strings.Contains(string(payload), "invalid_role") {
|
||||||
|
t.Fatalf("expected invalid_role, got %s", payload)
|
||||||
|
}
|
||||||
|
requireNoFile(t, filepath.Join(h.notePath, "guarded-comments.json"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty comment content returns 400 invalid_comment", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json",
|
||||||
|
`{"author":"ana","role":"user","content":" "}`, http.StatusBadRequest)
|
||||||
|
if !strings.Contains(string(payload), "invalid_comment") {
|
||||||
|
t.Fatalf("expected invalid_comment, got %s", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed JSON returns 400 invalid_body", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json", `{"author":`, http.StatusBadRequest)
|
||||||
|
if !strings.Contains(string(payload), "invalid_body") {
|
||||||
|
t.Fatalf("expected invalid_body, got %s", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("traversal slugs are refused", func(t *testing.T) {
|
||||||
|
for _, path := range []string{
|
||||||
|
"/api/docs/..%2F..%2Fetc%2Fpasswd",
|
||||||
|
"/api/docs/%2Fetc%2Fpasswd",
|
||||||
|
} {
|
||||||
|
h.expectStatus("POST", path, "text/markdown", "nope", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("oversized documents are refused", func(t *testing.T) {
|
||||||
|
huge := strings.Repeat("a", int(router.DefaultMaxDocBytes)+1024)
|
||||||
|
h.expectStatus("POST", "/api/docs/huge", "text/markdown", huge, http.StatusBadRequest)
|
||||||
|
requireNoFile(t, filepath.Join(h.docsDir, "huge.md"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown API routes return JSON 404, not an HTML page", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/nope", "", "", http.StatusNotFound)
|
||||||
|
if !strings.Contains(string(payload), "not_found") {
|
||||||
|
t.Fatalf("expected a JSON error, got %s", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestIntegrationCORS
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationCORS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
t.Run("preflight is answered for the Vite dev server", func(t *testing.T) {
|
||||||
|
resp, _ := h.request("OPTIONS", "/api/docs/core-service-api", "", "")
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if origin := resp.Header.Get("Access-Control-Allow-Origin"); origin != "*" {
|
||||||
|
t.Fatalf("expected a wildcard origin, got %q", origin)
|
||||||
|
}
|
||||||
|
methods := resp.Header.Get("Access-Control-Allow-Methods")
|
||||||
|
for _, verb := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} {
|
||||||
|
if !strings.Contains(methods, verb) {
|
||||||
|
t.Fatalf("expected %s in %q", verb, methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if headers := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(headers, "Content-Type") {
|
||||||
|
t.Fatalf("expected Content-Type to be allowed, got %q", headers)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cross-origin reads carry the CORS header", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", h.server.URL+"/api/docs", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Origin", "http://127.0.0.1:5173")
|
||||||
|
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if origin := resp.Header.Get("Access-Control-Allow-Origin"); origin != "*" {
|
||||||
|
t.Fatalf("expected a CORS header, got %q", origin)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an allow-list echoes only permitted origins", func(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
engine, err := storage.New(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
handler, err := router.NewWithConfig(router.Config{
|
||||||
|
Engine: engine,
|
||||||
|
AllowedOrigins: []string{"http://127.0.0.1:5173"},
|
||||||
|
Logger: discardLogger(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
for origin, wantEchoed := range map[string]bool{
|
||||||
|
"http://127.0.0.1:5173": true,
|
||||||
|
"http://evil.example.com": false,
|
||||||
|
} {
|
||||||
|
req, err := http.NewRequest("GET", server.URL+"/api/health", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
|
||||||
|
resp, err := server.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
got := resp.Header.Get("Access-Control-Allow-Origin")
|
||||||
|
if wantEchoed && got != origin {
|
||||||
|
t.Fatalf("expected %q to be echoed, got %q", origin, got)
|
||||||
|
}
|
||||||
|
if !wantEchoed && got == origin {
|
||||||
|
t.Fatalf("origin %q should not have been echoed", origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestIntegrationConcurrentLoad
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationConcurrentLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
docs = 40
|
||||||
|
perDoc = 30
|
||||||
|
commentsTotal = docs * perDoc
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run("concurrent writers all land on disk", func(t *testing.T) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, docs)
|
||||||
|
|
||||||
|
for d := 0; d < docs; d++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wg.Done()
|
||||||
|
title := fmt.Sprintf("Load Service %02d", index)
|
||||||
|
body := fmt.Sprintf("# %s\n\n## For Users\n\nUsage.\n\n## For Operators\n\nRunbook.\n\n## For Developers\n\nAPI.\n", title)
|
||||||
|
|
||||||
|
resp, payload := h.request("POST", "/api/docs/"+storage.Slugify(title), "text/markdown", body)
|
||||||
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||||
|
errs <- fmt.Errorf("save %s returned %d: %s", title, resp.StatusCode, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for n := 0; n < perDoc; n++ {
|
||||||
|
role := []string{"user", "operator", "developer"}[n%3]
|
||||||
|
comment := fmt.Sprintf(
|
||||||
|
`{"author":"load-%02d","role":%q,"content":"comment %d"}`, index, role, n)
|
||||||
|
resp, payload := h.request("POST", "/api/docs/"+storage.Slugify(title)+"/comments", "application/json", comment)
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
errs <- fmt.Errorf("comment on %s returned %d: %s", title, resp.StatusCode, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the filesystem holds every document", func(t *testing.T) {
|
||||||
|
entries, err := os.ReadDir(h.docsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != docs {
|
||||||
|
t.Fatalf("expected %d Markdown files, got %d", docs, len(entries))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the filesystem holds every comment", func(t *testing.T) {
|
||||||
|
entries, err := os.ReadDir(h.notePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != docs {
|
||||||
|
t.Fatalf("expected %d feedback files, got %d", docs, len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(h.notePath, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var stored []storage.Comment
|
||||||
|
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||||
|
t.Fatalf("%s is not a valid JSON array: %v", entry.Name(), err)
|
||||||
|
}
|
||||||
|
total += len(stored)
|
||||||
|
}
|
||||||
|
if total != commentsTotal {
|
||||||
|
t.Fatalf("expected %d comments across all files, got %d", commentsTotal, total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the API agrees with the filesystem", func(t *testing.T) {
|
||||||
|
payload := h.expectStatus("GET", "/api/stats", "", "", http.StatusOK)
|
||||||
|
stats := decodeJSON[struct {
|
||||||
|
Docs int `json:"docs"`
|
||||||
|
Comments int `json:"comments"`
|
||||||
|
}](t, payload)
|
||||||
|
|
||||||
|
if stats.Docs != docs {
|
||||||
|
t.Fatalf("expected %d docs, got %d", docs, stats.Docs)
|
||||||
|
}
|
||||||
|
if stats.Comments != commentsTotal {
|
||||||
|
t.Fatalf("expected %d comments, got %d", commentsTotal, stats.Comments)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no temp files leaked and every comment is badged", func(t *testing.T) {
|
||||||
|
entries, err := os.ReadDir(h.docsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".godoc-tmp-") {
|
||||||
|
t.Fatalf("leaked atomic-write temp file: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for d := 0; d < docs; d++ {
|
||||||
|
slug := storage.Slugify(fmt.Sprintf("Load Service %02d", d))
|
||||||
|
raw := requireFile(t, filepath.Join(h.notePath, slug+"-comments.json"))
|
||||||
|
var stored []storage.Comment
|
||||||
|
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(stored) != perDoc {
|
||||||
|
t.Fatalf("%s: expected %d comments, got %d", slug, perDoc, len(stored))
|
||||||
|
}
|
||||||
|
for _, comment := range stored {
|
||||||
|
if comment.Badge != comment.Role.Badge() {
|
||||||
|
t.Fatalf("%s: comment %s has badge %q for role %s", slug, comment.ID, comment.Badge, comment.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestIntegrationPersistenceAcrossRestart
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationPersistenceAcrossRestart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
|
||||||
|
// First "process": write through the API.
|
||||||
|
engine, err := storage.New(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
handler, err := router.NewWithConfig(router.Config{Engine: engine, Logger: discardLogger()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := httptest.NewServer(handler)
|
||||||
|
|
||||||
|
post := func(method, path, contentType, body string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(method, first.URL+path, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
resp, err := first.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
payload, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("%s %s returned %d: %s", method, path, resp.StatusCode, payload)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
post("POST", "/api/docs/Persistent%20Store", "text/markdown", "# Persistent Store\n\nSurvives restarts.\n")
|
||||||
|
post("POST", "/api/docs/persistent-store/comments", "application/json",
|
||||||
|
`{"author":"ana","role":"developer","content":"still here after a restart"}`)
|
||||||
|
first.Close()
|
||||||
|
|
||||||
|
// Second "process": a brand new engine over the same directory.
|
||||||
|
restarted, err := storage.New(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
handler, err = router.NewWithConfig(router.Config{Engine: restarted, Logger: discardLogger()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := httptest.NewServer(handler)
|
||||||
|
defer second.Close()
|
||||||
|
|
||||||
|
t.Run("the document survives", func(t *testing.T) {
|
||||||
|
resp, err := second.Client().Get(second.URL + "/api/docs/persistent-store?format=json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
doc := decodeJSON[struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}](t, mustReadAll(resp))
|
||||||
|
|
||||||
|
if doc.Title != "Persistent Store" {
|
||||||
|
t.Fatalf("unexpected title %q", doc.Title)
|
||||||
|
}
|
||||||
|
if !strings.Contains(doc.Content, "Survives restarts.") {
|
||||||
|
t.Fatalf("unexpected content %q", doc.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the feedback survives with its badge", func(t *testing.T) {
|
||||||
|
resp, err := second.Client().Get(second.URL + "/api/docs/persistent-store/comments")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
listed := decodeJSON[struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Comments []storage.Comment `json:"comments"`
|
||||||
|
}](t, mustReadAll(resp))
|
||||||
|
|
||||||
|
if listed.Count != 1 {
|
||||||
|
t.Fatalf("expected 1 comment, got %d", listed.Count)
|
||||||
|
}
|
||||||
|
if listed.Comments[0].Badge != "🔴" {
|
||||||
|
t.Fatalf("expected the developer badge, got %q", listed.Comments[0].Badge)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadAll(resp *http.Response) []byte {
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationTimingBudget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
h.expectStatus("POST", "/api/docs/timing", "text/markdown", strings.Repeat("Line of content.\n\n", 500), http.StatusCreated)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
h.expectStatus("POST", "/api/docs/timing/comments", "application/json",
|
||||||
|
fmt.Sprintf(`{"author":"bench","role":"user","content":"note %d"}`, i), http.StatusCreated)
|
||||||
|
}
|
||||||
|
h.expectStatus("GET", "/api/docs/timing", "", "", http.StatusOK)
|
||||||
|
|
||||||
|
if elapsed := time.Since(start); elapsed > 30*time.Second {
|
||||||
|
t.Fatalf("write path took %s, which is far too slow for a flat-file engine", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/tests/logger_test.go
Normal file
12
backend/tests/logger_test.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// discardLogger silences the router's request log so `go test -v` output stays
|
||||||
|
// focused on assertions.
|
||||||
|
func discardLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
33
backend/webui/dist.placeholder/index.html
Normal file
33
backend/webui/dist.placeholder/index.html
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>go-doc · frontend not built</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #0b0f14;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font: 16px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
main { max-width: 46rem; padding: 3rem; }
|
||||||
|
h1 { color: #f8fafc; font-size: 1.6rem; }
|
||||||
|
code { color: #7dd3fc; }
|
||||||
|
ul { padding-left: 1.2rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>go-doc API is running</h1>
|
||||||
|
<p>This is the placeholder page shipped with the Go binary. The React bundle has not been compiled into it yet.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Run <code>make frontend-run</code> for the Vite dev server on port 5173.</li>
|
||||||
|
<li>Run <code>make build</code> to embed the production bundle into the binary.</li>
|
||||||
|
</ul>
|
||||||
|
<p>API: <code>/api/health</code> · <code>/api/docs</code> · <code>/api/docs/{slug}</code> · <code>/api/docs/{slug}/comments</code></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
backend/webui/dist/index.html
vendored
Normal file
14
backend/webui/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="go-doc — segmented documentation and audience feedback for users, operators and developers" />
|
||||||
|
<title>Go Doc: Application Documentation tool.</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-76KU_cS9.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-fwxz_0Za.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
backend/webui/embed.go
Normal file
31
backend/webui/embed.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Package webui embeds the compiled React bundle produced by `make build`.
|
||||||
|
//
|
||||||
|
// The `dist` directory is populated by the build pipeline (npm run build copies
|
||||||
|
// frontend/dist into backend/webui/dist). A placeholder index.html is checked
|
||||||
|
// in so the backend always compiles and runs, even in a fresh checkout where
|
||||||
|
// the frontend has not been built yet.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
// FS returns the root of the embedded bundle, or nil when the placeholder build
|
||||||
|
// is still in place and no real frontend bundle has been compiled.
|
||||||
|
func FS() fs.FS {
|
||||||
|
sub, err := fs.Sub(assets, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(sub, "index.html"); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets exposes the raw embedded filesystem, including the dist prefix.
|
||||||
|
func Assets() fs.FS { return assets }
|
||||||
229
data/docs/go-doc.md
Normal file
229
data/docs/go-doc.md
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
# go-doc
|
||||||
|
|
||||||
|
**go-doc** is a flat-file documentation and audience-feedback platform. It isolates guidance for
|
||||||
|
three audiences inside a single Markdown document, lets anyone annotate that document as a **User 🟢**,
|
||||||
|
**Operator 🔵** or **Developer 🔴**, and stores everything as plain files you can read, diff and commit.
|
||||||
|
|
||||||
|
```
|
||||||
|
data/docs/go-doc.md # raw Markdown, one file per document
|
||||||
|
data/comments/go-doc-comments.json # JSON array of audience feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
One file carries all three viewpoints. Sections are delimited by level-2 headings and classified by the
|
||||||
|
audience they address, so the reader renders the tab that matters to you while the shared preamble
|
||||||
|
stays visible to everyone. Feedback badges are derived **server-side** from the stored role, so a
|
||||||
|
client cannot spoof another audience's colour by submitting a `badge` field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Users
|
||||||
|
|
||||||
|
You use the product. You are not deploying it and you are not extending it — you want the shortest
|
||||||
|
path from "I have an account" to "the thing I came here to do actually worked".
|
||||||
|
|
||||||
|
### Getting oriented
|
||||||
|
|
||||||
|
Open the app and you land on the document index: every Markdown file in `data/docs/`, newest first.
|
||||||
|
Each entry shows its size, last modification date and how much audience feedback it has collected.
|
||||||
|
|
||||||
|
### Switching to your view
|
||||||
|
|
||||||
|
Every document has three tabs along the top:
|
||||||
|
|
||||||
|
| Tab | Badge | What it shows you |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| User | 🟢 | Day-to-day workflows and everyday tasks |
|
||||||
|
| Operator | 🔵 | Deployment, health checks, rollback |
|
||||||
|
| Developer | 🔴 | API contracts, schemas, integration notes |
|
||||||
|
|
||||||
|
Switching tabs swaps the guidance without duplicating content across files. The preamble above —
|
||||||
|
this section — always stays visible.
|
||||||
|
|
||||||
|
### Leaving feedback
|
||||||
|
|
||||||
|
The right-hand panel is your feedback channel. Pick the badge that matches who you are, write your
|
||||||
|
note, and post it. It is stored as one entry in a JSON array next to the document, so your note is
|
||||||
|
never lost and never overwritten by someone else's.
|
||||||
|
|
||||||
|
Filter the list by any badge to focus on the feedback relevant to you. Comments carry the author,
|
||||||
|
the role, the badge and a timestamp, and you can delete your own.
|
||||||
|
|
||||||
|
### Editing a document
|
||||||
|
|
||||||
|
Press **Edit** to open the hybrid editor. It has two synchronised views:
|
||||||
|
|
||||||
|
- **Visual Edit (WYSIWYG)** — a rich surface where bold, italic, inline code, headings, lists,
|
||||||
|
quotes and links are applied directly. The Markdown underneath updates as you type.
|
||||||
|
- **Source Code (Markdown)** — the raw Markdown with a live preview beside it.
|
||||||
|
|
||||||
|
Both views write to the same string, so you can start in one and finish in the other. `Ctrl/Cmd+S`
|
||||||
|
saves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Operators
|
||||||
|
|
||||||
|
You keep the thing running. Your concern is state convergence, blast radius, and how fast you can
|
||||||
|
get back to a known-good position when a release goes wrong.
|
||||||
|
|
||||||
|
### Storage layout
|
||||||
|
|
||||||
|
State is a directory, not a database:
|
||||||
|
|
||||||
|
```
|
||||||
|
data/docs/<slug>.md # one Markdown file per document
|
||||||
|
data/comments/<slug>-comments.json # one JSON array per document
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no daemon to restart and no migration to run. Backups are file copies; the directory layout
|
||||||
|
*is* the schema.
|
||||||
|
|
||||||
|
### Write guarantees
|
||||||
|
|
||||||
|
Every mutation is a temp-file write, `fsync`, `rename`, then `fsync` of the parent directory. The
|
||||||
|
consequences matter operationally:
|
||||||
|
|
||||||
|
- A reader never observes a half-written document.
|
||||||
|
- A crash mid-write leaves either the previous file or the new one — never a truncated one.
|
||||||
|
- Feedback read-modify-write cycles are serialised per slug, so a burst of comments cannot interleave
|
||||||
|
and lose entries.
|
||||||
|
|
||||||
|
Temporary files are named `.godoc-tmp-*` and are excluded from the document index. If you ever see
|
||||||
|
one survive a crash, that directory is safe to inspect and safe to delete.
|
||||||
|
|
||||||
|
### Health checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://localhost:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{"status":"ok","data_root":"...","time":"..."}`. If the process answers at all, the data
|
||||||
|
directory was opened successfully at boot.
|
||||||
|
|
||||||
|
### Store-wide census
|
||||||
|
|
||||||
|
`GET /api/stats` reports total documents, total comments and a per-role breakdown — the fastest way
|
||||||
|
to confirm a restore or a sync actually landed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "docs": 120, "comments": 3000, "by_role": { "user": 1000, "operator": 1000, "developer": 1000 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
There is nothing to roll back except the files. Restore the previous `data/` snapshot, restart the
|
||||||
|
process, and confirm with the index endpoint. Deletes are permanent and are not journalled, so keep
|
||||||
|
your backups.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Developers
|
||||||
|
|
||||||
|
You integrate with it or you change it. You care about contracts, determinism, and failure modes.
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/health` | Liveness and the resolved data root |
|
||||||
|
| `GET` | `/api/roles` | Role → badge → colour catalogue for the UI |
|
||||||
|
| `GET` | `/api/docs` | Index; `?q=` searches, `?counts=1` adds comment counts |
|
||||||
|
| `GET` | `/api/docs/:slug` | Raw Markdown (`text/markdown`), or `?format=json` |
|
||||||
|
| `POST` | `/api/docs/:slug` | Create or fully replace; `text/markdown` or JSON |
|
||||||
|
| `PUT` | `/api/docs/:slug` | Alias of `POST` |
|
||||||
|
| `DELETE` | `/api/docs/:slug` | Permanent delete; cascades feedback |
|
||||||
|
| `GET` | `/api/docs/:slug/comments` | JSON list of comments |
|
||||||
|
| `POST` | `/api/docs/:slug/comments` | Append `{author, role, content}` |
|
||||||
|
| `DELETE` | `/api/docs/:slug/comments/:id` | Remove one comment |
|
||||||
|
| `GET` | `/api/docs/:slug/stats` | Totals, per-role tally, time bounds |
|
||||||
|
| `GET` | `/api/stats` | Store-wide totals |
|
||||||
|
|
||||||
|
### Slug contract
|
||||||
|
|
||||||
|
Titles are slugified into lower-case, hyphen-separated identifiers:
|
||||||
|
|
||||||
|
```
|
||||||
|
"Core Service API" -> core-service-api
|
||||||
|
"Überwachen der Dienste" -> uberwachen-der-dienste
|
||||||
|
"API_v2 (Internal)" -> api-v2-internal
|
||||||
|
"!!!": -> rejected
|
||||||
|
```
|
||||||
|
|
||||||
|
Diacritics fold to ASCII, separator runs collapse to a single hyphen, the result is trimmed and
|
||||||
|
capped at 120 characters. Slugs containing `/`, `\`, `..`, control characters or NUL bytes are
|
||||||
|
rejected with `400 invalid_slug` before any filesystem call, so a URL can never escape `data/docs`.
|
||||||
|
|
||||||
|
Because normalisation is idempotent, `/api/docs/Core Service API` and `/api/docs/core-service-api`
|
||||||
|
address the same document. The `POST` response echoes the canonical slug — always use that value.
|
||||||
|
|
||||||
|
### Comment contract
|
||||||
|
|
||||||
|
`POST /api/docs/:slug/comments` accepts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "author": "ana", "role": "Developer", "content": "The retry budget is undocumented." }
|
||||||
|
```
|
||||||
|
|
||||||
|
The stored record is authoritative — the badge is derived from the role, and an incoming `badge`
|
||||||
|
field is ignored:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "c1a105f4684c_9b1c081def3b00a4",
|
||||||
|
"author": "ana",
|
||||||
|
"role": "developer",
|
||||||
|
"badge": "🔴",
|
||||||
|
"content": "The retry budget is undocumented.",
|
||||||
|
"timestamp": "2026-10-04T08:07:45.996Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Roles are canonicalised case-insensitively (`Developer`, `DEV`, `developer` are the same role) and
|
||||||
|
anything else returns `400 invalid_role`. Empty content returns `400 invalid_comment`. An unset
|
||||||
|
author defaults to the role label. IDs are a millisecond prefix plus 8 random bytes, so they sort
|
||||||
|
roughly chronologically and do not collide under load.
|
||||||
|
|
||||||
|
### Error shapes
|
||||||
|
|
||||||
|
Every failure is JSON, so clients never have to parse an HTML error page:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": { "code": "invalid_role", "message": "storage: invalid role: \"root\" ...", "request_id": "" },
|
||||||
|
"status": 400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Codes: `not_found`, `invalid_slug`, `invalid_role`, `invalid_comment`, `invalid_body`,
|
||||||
|
`body_too_large`, `internal_error`.
|
||||||
|
|
||||||
|
### Limits
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Document body | 5 MiB |
|
||||||
|
| Comment body | 256 KiB |
|
||||||
|
| Slug length | 120 characters |
|
||||||
|
| Author length | 120 characters |
|
||||||
|
|
||||||
|
Exceeding the document or comment ceiling returns `400 body_too_large` and writes nothing.
|
||||||
|
|
||||||
|
### Embedding the SPA
|
||||||
|
|
||||||
|
`make build` copies `frontend/dist` into `backend/webui/dist`, where `//go:embed all:dist` picks it
|
||||||
|
up. The compiled binary then serves the SPA on `/` and the API on `/api/*` from one process with no
|
||||||
|
runtime dependencies. Client-side routes such as `/docs/core-service-api` fall back to `index.html`;
|
||||||
|
`/api/*` never does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
Other go-* projects documented in this workspace:
|
||||||
|
|
||||||
|
- [go-bananas](go-bananas.md) — Runtime design-token service
|
||||||
|
- [go-guess](go-guess.md) — Infrastructure guessing game
|
||||||
|
- [go-loose](go-loose.md) — Lightweight auth middleware
|
||||||
|
- [go-mad](go-mad.md) — Markdown-driven development
|
||||||
|
- [go-places](go-places.md) — Location-aware services
|
||||||
|
- [go-tell](go-tell.md) — Content CMS
|
||||||
71
docker-compose.yml
Normal file
71
docker-compose.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# =============================================================================
|
||||||
|
# go-doc development stack.
|
||||||
|
#
|
||||||
|
# Both services bind-mount the repository so edits are live-synchronised with the
|
||||||
|
# host: the Go backend recompiles through `go run` and Vite serves hot module
|
||||||
|
# replacement. The data directory is mounted as well, so the flat-file Markdown
|
||||||
|
# and JSON written by a container is the same file the host tooling reads.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
name: go-doc
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: go-doc/api:dev
|
||||||
|
container_name: godoc-api
|
||||||
|
command: ["go", "run", "main.go", "-addr", "0.0.0.0:8080"]
|
||||||
|
environment:
|
||||||
|
GO_DOC_DATA_DIR: /app/data
|
||||||
|
GO_DOC_LOG_FORMAT: json
|
||||||
|
GO_DOC_LOG_LEVEL: info
|
||||||
|
GO_LOOSE_BASE_URL: https://auth-dev.urpi.be
|
||||||
|
GO_LOOSE_CLIENT_ID: go-doc
|
||||||
|
GO_LOOSE_CLIENT_SECRET: local-dev-secret
|
||||||
|
GO_LOOSE_REDIRECT_URL: https://doc-dev.urpi.be/auth/callback
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app/backend
|
||||||
|
- ./data:/app/data
|
||||||
|
- api-go-cache:/root/.cache/go-build
|
||||||
|
- api-go-mod:/go/pkg/mod
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
working_dir: /app/backend
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
networks: [godoc]
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: go-doc/frontend:dev
|
||||||
|
container_name: godoc-frontend
|
||||||
|
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||||
|
environment:
|
||||||
|
GO_DOC_BACKEND: http://api:8080
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- frontend-node-modules:/app/node_modules
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
working_dir: /app
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [godoc]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
api-go-cache:
|
||||||
|
api-go-mod:
|
||||||
|
frontend-node-modules:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
godoc:
|
||||||
|
driver: bridge
|
||||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Development image for the Vite dev server.
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first so the layer caches across source edits. The
|
||||||
|
# compose bind-mount keeps node_modules in a named volume for the same reason.
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="go-doc — segmented documentation and audience feedback for users, operators and developers" />
|
||||||
|
<title>Go Doc: Application Documentation tool.</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1607
frontend/package-lock.json
generated
Normal file
1607
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "go-doc-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "go-doc documentation and audience feedback SPA",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "^3.4.16",
|
||||||
|
"lucide-react": "^1.51.0",
|
||||||
|
"marked": "^18.0.14",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"vite": "^8.3.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
170
frontend/src/App.jsx
Normal file
170
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ArrowLeft, BookOpen, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import DocEditor from './components/DocEditor.jsx';
|
||||||
|
import DocIndex from './components/DocIndex.jsx';
|
||||||
|
import DocViewer from './components/DocViewer.jsx';
|
||||||
|
import RoleBadge from './components/RoleBadge.jsx';
|
||||||
|
import { ROLES, api } from './lib/api.js';
|
||||||
|
import { docHref, navigate, useRoute } from './lib/router.js';
|
||||||
|
import { mountAccountMenu } from './lib/account.js';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountAccountMenu();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-ink-900">
|
||||||
|
<SiteHeader />
|
||||||
|
<main>
|
||||||
|
<RouteView key={`${route.name}:${route.slug ?? ''}`} route={route} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SiteHeader() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-20 border-b border-ink-700 bg-ink-900/85 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-[1400px] flex-wrap items-center gap-4 px-6 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex items-center gap-2.5 transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-sky-500 font-mono text-sm font-bold text-ink-950">
|
||||||
|
go
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold tracking-tight text-white">go-doc</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="hidden font-mono text-[11px] text-ink-500 sm:inline">
|
||||||
|
flat-file documentation & feedback
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<nav className="ml-auto flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="ml-2 inline-flex items-center gap-1.5 rounded-lg border border-ink-700 px-3 py-1.5 text-xs font-medium text-ink-300 transition hover:bg-ink-800 hover:text-white"
|
||||||
|
>
|
||||||
|
<BookOpen size={13} aria-hidden="true" />
|
||||||
|
Index
|
||||||
|
</button>
|
||||||
|
<div data-account-menu className="ml-2" />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteView({ route }) {
|
||||||
|
switch (route.name) {
|
||||||
|
case 'index':
|
||||||
|
return <DocIndex />;
|
||||||
|
case 'view':
|
||||||
|
return <DocViewer slug={route.slug} />;
|
||||||
|
case 'edit':
|
||||||
|
return <EditorRoute slug={route.slug} />;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="grid place-items-center py-32 text-center">
|
||||||
|
<p className="text-sm text-ink-400">
|
||||||
|
Nothing here. <RouteLink to="/">Return to the documentation index</RouteLink>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteLink({ to, children }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => navigate(to)} className="text-sky-400 underline">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads a document, then hands it to the hybrid WYSIWYG/Markdown editor. */
|
||||||
|
function EditorRoute({ slug }) {
|
||||||
|
const [doc, setDoc] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setDoc(await api.getDoc(slug));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function handleSave({ slug: target, title, content }) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const saved = await api.saveDoc(target, content, title);
|
||||||
|
setDoc((current) => (current ? { ...current, ...saved, content } : current));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid place-items-center py-32 text-sm text-ink-400">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||||
|
Loading editor…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !doc) {
|
||||||
|
return (
|
||||||
|
<div className="grid place-items-center py-32 text-center">
|
||||||
|
<p className="text-sm text-ink-400">
|
||||||
|
Cannot edit “{slug}”: {error?.message ?? 'document not found'}.{' '}
|
||||||
|
<RouteLink to="/">Back to the index</RouteLink>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-[calc(100vh-3.25rem)] max-w-[1400px] flex-col gap-3 px-6 py-5">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-ink-400">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(docHref(slug))}
|
||||||
|
className="inline-flex items-center gap-1.5 transition hover:text-ink-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={13} aria-hidden="true" />
|
||||||
|
Back to reader
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<DocEditor
|
||||||
|
slug={doc.slug}
|
||||||
|
title={doc.title}
|
||||||
|
initialContent={doc.content}
|
||||||
|
onSave={handleSave}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/src/components/CommentPanel.jsx
Normal file
148
frontend/src/components/CommentPanel.jsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { MessageSquarePlus, Send, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import RoleBadge from './RoleBadge.jsx';
|
||||||
|
import { ROLES, api } from '../lib/api.js';
|
||||||
|
|
||||||
|
const EMPTY_AUTHOR = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommentPanel lists the audience feedback attached to a document and lets any
|
||||||
|
* visitor post as User, Operator or Developer. The badge is rendered from the
|
||||||
|
* role the server stored, never from client input, so a Developer comment is
|
||||||
|
* always shown with the red 🔴 badge.
|
||||||
|
*/
|
||||||
|
export default function CommentPanel({ slug, comments, onRefresh }) {
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
const [author, setAuthor] = useState(EMPTY_AUTHOR);
|
||||||
|
const [role, setRole] = useState(ROLES[0].role);
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const tally = { all: comments.length };
|
||||||
|
for (const { role: r } of ROLES) tally[r] = 0;
|
||||||
|
for (const comment of comments) {
|
||||||
|
if (tally[comment.role] !== undefined) tally[comment.role] += 1;
|
||||||
|
}
|
||||||
|
return tally;
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() => (filter === 'all' ? comments : comments.filter((c) => c.role === filter)),
|
||||||
|
[comments, filter],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function submit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!content.trim() || busy) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.createComment(slug, { author: author.trim(), role, content: content.trim() });
|
||||||
|
setContent('');
|
||||||
|
setNotice('Comment recorded');
|
||||||
|
window.setTimeout(() => setNotice(''), 2000);
|
||||||
|
await onRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
try {
|
||||||
|
await api.deleteComment(slug, id);
|
||||||
|
await onRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex h-full flex-col overflow-hidden rounded-xl border border-ink-700 bg-ink-850">
|
||||||
|
<header className="flex flex-wrap items-center gap-2 border-b border-ink-700 bg-ink-800 px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Audience feedback</h2>
|
||||||
|
<span className="rounded-full bg-ink-700 px-2 py-0.5 font-mono text-[11px] text-ink-300">
|
||||||
|
{comments.length}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-ink-400">
|
||||||
|
No feedback yet. Be the first to leave a note for this audience.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
visible.map((comment) => (
|
||||||
|
<article
|
||||||
|
key={comment.id}
|
||||||
|
className="group rounded-lg border border-ink-700 bg-ink-900/60 p-3 transition hover:border-ink-600"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium text-ink-200">{comment.author}</span>
|
||||||
|
<time
|
||||||
|
dateTime={comment.timestamp}
|
||||||
|
className="ml-auto shrink-0 font-mono text-[11px] text-ink-500"
|
||||||
|
>
|
||||||
|
{new Date(comment.timestamp).toLocaleString()}
|
||||||
|
</time>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(comment.id)}
|
||||||
|
aria-label="Delete comment"
|
||||||
|
className="rounded p-1 text-ink-600 opacity-0 transition hover:text-red-400 focus:opacity-100 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-ink-300">
|
||||||
|
{comment.content.trim()}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="border-t border-ink-700 bg-ink-800 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<MessageSquarePlus size={15} className="text-ink-400" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
value={author}
|
||||||
|
onChange={(event) => setAuthor(event.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
aria-label="Author"
|
||||||
|
className="w-32 rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 text-xs text-ink-200 outline-none transition focus:border-ink-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
placeholder={`Leave a note for the ${ROLES.find((r) => r.role === role).label.toLowerCase()} audience…`}
|
||||||
|
aria-label="Comment content"
|
||||||
|
rows={3}
|
||||||
|
className="mt-2 w-full resize-none rounded-md border border-ink-700 bg-ink-900 px-3 py-2 text-sm text-ink-200 outline-none transition focus:border-ink-600"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className={`text-xs ${error ? 'text-red-400' : notice ? 'text-emerald-400' : 'text-ink-500'}`}>
|
||||||
|
{error || notice || 'Feedback is stored as JSON next to the document.'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !content.trim()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-ink-700 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-ink-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send size={13} aria-hidden="true" />
|
||||||
|
{busy ? 'Posting' : 'Post comment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
421
frontend/src/components/DocEditor.jsx
Normal file
421
frontend/src/components/DocEditor.jsx
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Braces,
|
||||||
|
Code,
|
||||||
|
Eye,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Italic,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Pilcrow,
|
||||||
|
Quote,
|
||||||
|
Redo2,
|
||||||
|
Save,
|
||||||
|
Strikethrough,
|
||||||
|
Undo2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { countWords, htmlToMarkdown, markdownToHtml } from '../lib/markdown.js';
|
||||||
|
|
||||||
|
const MODES = {
|
||||||
|
VISUAL: 'visual',
|
||||||
|
SOURCE: 'source',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DocEditor is the hybrid writer for go-doc.
|
||||||
|
*
|
||||||
|
* Two modes share one source of truth -- the Markdown string held in state:
|
||||||
|
*
|
||||||
|
* Visual (WYSIWYG) a contentEditable surface pre-rendered from the Markdown.
|
||||||
|
* Structural edits (bold, italic, inline code, headings,
|
||||||
|
* lists, quotes, links) are applied to the DOM and the
|
||||||
|
* Markdown is regenerated on every input event.
|
||||||
|
*
|
||||||
|
* Source (Markdown) a plain textarea paired with a live preview pane. The
|
||||||
|
* preview re-renders as you type, so the Markdown stays
|
||||||
|
* readable while it is being written.
|
||||||
|
*
|
||||||
|
* The innerHTML of the visual surface is written only when the document or mode
|
||||||
|
* actually changes. React never re-renders it on each keystroke, which is what
|
||||||
|
* keeps the caret from jumping while you type.
|
||||||
|
*/
|
||||||
|
export default function DocEditor({ slug, initialContent = '', title: initialTitle = '', onSave, saving = false }) {
|
||||||
|
const [mode, setMode] = useState(MODES.VISUAL);
|
||||||
|
const [markdown, setMarkdown] = useState(initialContent);
|
||||||
|
const [title, setTitle] = useState(initialTitle);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [focusMode, setFocusMode] = useState(null);
|
||||||
|
|
||||||
|
const surfaceRef = useRef(null);
|
||||||
|
const injectedRef = useRef(null);
|
||||||
|
const docKeyRef = useRef(null);
|
||||||
|
const sourceRef = useRef(null);
|
||||||
|
|
||||||
|
const previewHtml = useMemo(() => markdownToHtml(markdown), [markdown]);
|
||||||
|
const stats = useMemo(
|
||||||
|
() => ({ words: countWords(markdown), chars: markdown.length, lines: markdown.split('\n').length }),
|
||||||
|
[markdown],
|
||||||
|
);
|
||||||
|
|
||||||
|
// A new document resets the editor and re-seeds both surfaces.
|
||||||
|
useEffect(() => {
|
||||||
|
setMarkdown(initialContent);
|
||||||
|
setTitle(initialTitle);
|
||||||
|
setDirty(false);
|
||||||
|
injectedRef.current = null;
|
||||||
|
docKeyRef.current = null;
|
||||||
|
}, [initialContent, initialTitle, slug]);
|
||||||
|
|
||||||
|
// Push Markdown into the visual surface whenever it diverges from what we last
|
||||||
|
// injected. Typing inside the surface produces a Markdown string equal to the
|
||||||
|
// injected one, so the caret survives untouched.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== MODES.VISUAL) return;
|
||||||
|
const surface = surfaceRef.current;
|
||||||
|
if (!surface) return;
|
||||||
|
|
||||||
|
if (docKeyRef.current !== slug && injectedRef.current !== markdown) {
|
||||||
|
surface.innerHTML = markdownToHtml(markdown);
|
||||||
|
injectedRef.current = markdown;
|
||||||
|
docKeyRef.current = slug;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (injectedRef.current !== markdown) {
|
||||||
|
surface.innerHTML = markdownToHtml(markdown);
|
||||||
|
injectedRef.current = markdown;
|
||||||
|
}
|
||||||
|
}, [mode, markdown, slug]);
|
||||||
|
|
||||||
|
const syncFromSurface = useCallback(() => {
|
||||||
|
const surface = surfaceRef.current;
|
||||||
|
if (!surface) return;
|
||||||
|
const next = htmlToMarkdown(surface.innerHTML);
|
||||||
|
injectedRef.current = next;
|
||||||
|
setMarkdown(next);
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runCommand = useCallback(
|
||||||
|
(command, value = null) => {
|
||||||
|
const surface = surfaceRef.current;
|
||||||
|
if (!surface) return;
|
||||||
|
surface.focus();
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
syncFromSurface();
|
||||||
|
setFocusMode(MODES.VISUAL);
|
||||||
|
},
|
||||||
|
[syncFromSurface],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleHeading = useCallback(
|
||||||
|
(level) => {
|
||||||
|
const surface = surfaceRef.current;
|
||||||
|
const current = document.queryCommandValue('formatBlock')?.toLowerCase();
|
||||||
|
runCommand('formatBlock', current === `h${level}` ? 'p' : `h${level}`);
|
||||||
|
},
|
||||||
|
[runCommand],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is no execCommand for inline code, so the selection is wrapped with
|
||||||
|
* backticks through the Range API.
|
||||||
|
*/
|
||||||
|
const toggleInlineCode = useCallback(() => {
|
||||||
|
const surface = surfaceRef.current;
|
||||||
|
if (!surface) return;
|
||||||
|
surface.focus();
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||||
|
runCommand('insertText', '`code`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const selected = selection.toString();
|
||||||
|
const wrapper = document.createElement('code');
|
||||||
|
wrapper.textContent = selected;
|
||||||
|
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(wrapper);
|
||||||
|
|
||||||
|
const after = document.createRange();
|
||||||
|
after.selectNodeContents(wrapper);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(after);
|
||||||
|
|
||||||
|
syncFromSurface();
|
||||||
|
setFocusMode(MODES.VISUAL);
|
||||||
|
}, [runCommand, syncFromSurface]);
|
||||||
|
|
||||||
|
const createLink = useCallback(() => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const selected = selection?.toString() ?? '';
|
||||||
|
const href = window.prompt('Link target', 'https://');
|
||||||
|
if (!href) return;
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
runCommand('createLink', href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.execCommand('createLink', false, href);
|
||||||
|
syncFromSurface();
|
||||||
|
}, [runCommand, syncFromSurface]);
|
||||||
|
|
||||||
|
const switchMode = useCallback(
|
||||||
|
(next) => {
|
||||||
|
if (next === mode) return;
|
||||||
|
if (next === MODES.SOURCE) {
|
||||||
|
// Freeze the current Markdown; the source textarea becomes the source
|
||||||
|
// of truth and the preview follows it live.
|
||||||
|
setMarkdown(sourceRef.current?.value ?? markdown);
|
||||||
|
} else {
|
||||||
|
injectedRef.current = null;
|
||||||
|
docKeyRef.current = null;
|
||||||
|
}
|
||||||
|
setMode(next);
|
||||||
|
setFocusMode(next);
|
||||||
|
},
|
||||||
|
[mode, markdown],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSourceChange = useCallback((event) => {
|
||||||
|
setMarkdown(event.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
const content = mode === MODES.SOURCE ? (sourceRef.current?.value ?? markdown) : markdown;
|
||||||
|
setStatus('Saving…');
|
||||||
|
try {
|
||||||
|
await onSave?.({ slug, title, content });
|
||||||
|
setDirty(false);
|
||||||
|
setStatus(`Saved ${new Date().toLocaleTimeString()}`);
|
||||||
|
window.setTimeout(() => setStatus(''), 2600);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Save failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}, [markdown, mode, onSave, slug, title]);
|
||||||
|
|
||||||
|
// Ctrl/Cmd+S saves; Ctrl/Cmd+B and Ctrl/Cmd+I format inside the visual surface.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event) => {
|
||||||
|
const meta = event.metaKey || event.ctrlKey;
|
||||||
|
if (!meta) return;
|
||||||
|
|
||||||
|
if (event.key.toLowerCase() === 's') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode !== MODES.VISUAL) return;
|
||||||
|
|
||||||
|
if (event.key.toLowerCase() === 'b') {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand('bold');
|
||||||
|
} else if (event.key.toLowerCase() === 'i') {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand('italic');
|
||||||
|
} else if (event.key.toLowerCase() === 'k') {
|
||||||
|
event.preventDefault();
|
||||||
|
createLink();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [createLink, handleSave, mode, runCommand]);
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{ label: 'Bold', icon: Bold, action: () => runCommand('bold'), group: 'inline' },
|
||||||
|
{ label: 'Italic', icon: Italic, action: () => runCommand('italic'), group: 'inline' },
|
||||||
|
{ label: 'Inline code', icon: Code, action: toggleInlineCode, group: 'inline' },
|
||||||
|
{ label: 'Strikethrough', icon: Strikethrough, action: () => runCommand('strikeThrough'), group: 'inline' },
|
||||||
|
{ label: 'Heading 1', icon: Heading1, action: () => toggleHeading(1), group: 'block' },
|
||||||
|
{ label: 'Heading 2', icon: Heading2, action: () => toggleHeading(2), group: 'block' },
|
||||||
|
{ label: 'Heading 3', icon: Heading3, action: () => toggleHeading(3), group: 'block' },
|
||||||
|
{ label: 'Bullet list', icon: List, action: () => runCommand('insertUnorderedList'), group: 'block' },
|
||||||
|
{ label: 'Numbered list', icon: ListOrdered, action: () => runCommand('insertOrderedList'), group: 'block' },
|
||||||
|
{ label: 'Quote', icon: Quote, action: () => runCommand('formatBlock', 'blockquote'), group: 'block' },
|
||||||
|
{ label: 'Paragraph', icon: Pilcrow, action: () => runCommand('formatBlock', 'p'), group: 'block' },
|
||||||
|
{ label: 'Code block', icon: Braces, action: () => runCommand('formatBlock', 'pre'), group: 'block' },
|
||||||
|
{ label: 'Link', icon: Eye, action: createLink, group: 'inline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-ink-700 bg-ink-850">
|
||||||
|
{/* Document identity + mode switch */}
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-ink-700 bg-ink-800 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitle(event.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
placeholder="Document title"
|
||||||
|
className="w-full truncate rounded-md border border-transparent bg-transparent px-2 py-1 text-lg font-semibold text-white outline-none transition focus:border-ink-600 focus:bg-ink-900"
|
||||||
|
/>
|
||||||
|
<p className="px-2 font-mono text-xs text-ink-400">data/docs/{slug}.md</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Editor mode"
|
||||||
|
className="flex rounded-lg border border-ink-700 bg-ink-900 p-1"
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ id: MODES.VISUAL, label: 'Visual Edit (WYSIWYG)' },
|
||||||
|
{ id: MODES.SOURCE, label: 'Source Code (Markdown)' },
|
||||||
|
].map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
aria-selected={mode === option.id}
|
||||||
|
onClick={() => switchMode(option.id)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-xs font-medium transition ${
|
||||||
|
mode === option.id
|
||||||
|
? 'bg-ink-700 text-white shadow-sm'
|
||||||
|
: 'text-ink-300 hover:bg-ink-800 hover:text-ink-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-sky-500 px-3.5 py-2 text-xs font-semibold text-ink-950 transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Save size={14} aria-hidden="true" />
|
||||||
|
{saving ? 'Saving' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Toolbar: visible in both modes, but only wired up for the visual surface */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1 border-b border-ink-700 bg-ink-850 px-3 py-2">
|
||||||
|
{tools.map(({ label, icon: Icon, action, group }, index) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => {
|
||||||
|
if (mode !== MODES.VISUAL) {
|
||||||
|
setStatus('Switch to Visual Edit to use formatting tools');
|
||||||
|
window.setTimeout(() => setStatus(''), 1800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action();
|
||||||
|
}}
|
||||||
|
className={`rounded-md p-2 text-ink-300 transition hover:bg-ink-700 hover:text-white disabled:opacity-40 ${
|
||||||
|
(group === 'block' && index === 4) || (group === 'inline' && index === tools.length - 1)
|
||||||
|
? 'ml-2 border-l border-ink-700 pl-2.5'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-1 text-ink-400">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runCommand('undo')}
|
||||||
|
aria-label="Undo"
|
||||||
|
title="Undo"
|
||||||
|
className="rounded-md p-2 transition hover:bg-ink-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<Undo2 size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runCommand('redo')}
|
||||||
|
aria-label="Redo"
|
||||||
|
title="Redo"
|
||||||
|
className="rounded-md p-2 transition hover:bg-ink-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<Redo2 size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editing surface */}
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{mode === MODES.VISUAL ? (
|
||||||
|
<div className="relative min-h-0 w-full overflow-y-auto px-6 py-5">
|
||||||
|
<div
|
||||||
|
ref={surfaceRef}
|
||||||
|
className="md-body min-h-[60vh]"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
spellCheck
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-label="Visual markdown editor"
|
||||||
|
data-placeholder="Start writing your documentation…"
|
||||||
|
onInput={syncFromSurface}
|
||||||
|
onBlur={() => setFocusMode(null)}
|
||||||
|
onFocus={() => setFocusMode(MODES.VISUAL)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
ref={sourceRef}
|
||||||
|
defaultValue={markdown}
|
||||||
|
onChange={handleSourceChange}
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label="Markdown source"
|
||||||
|
className="min-h-0 w-1/2 resize-none border-r border-ink-700 bg-ink-950 px-5 py-5 font-mono text-[13px] leading-6 text-ink-200 outline-none"
|
||||||
|
/>
|
||||||
|
<div className="min-h-0 w-1/2 overflow-y-auto bg-ink-850 px-6 py-5">
|
||||||
|
<p className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-ink-400">
|
||||||
|
Live preview
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="md-body"
|
||||||
|
// Preview HTML is sanitised by markdownToHtml before it reaches here.
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<footer className="flex items-center justify-between gap-4 border-t border-ink-700 bg-ink-800 px-4 py-2 text-xs text-ink-400">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={dirty ? 'text-amber-400' : 'text-emerald-400'}>
|
||||||
|
{dirty ? 'Unsaved changes' : 'In sync'}
|
||||||
|
</span>
|
||||||
|
{status ? <span className="text-ink-300">{status}</span> : null}
|
||||||
|
{focusMode ? (
|
||||||
|
<span className="hidden font-mono text-ink-500 sm:inline">
|
||||||
|
mode: {focusMode}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 font-mono">
|
||||||
|
<span>{stats.words} words</span>
|
||||||
|
<span>{stats.chars} chars</span>
|
||||||
|
<span>{stats.lines} lines</span>
|
||||||
|
<span className="hidden sm:inline">Ctrl+S to save</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MODES };
|
||||||
187
frontend/src/components/DocIndex.jsx
Normal file
187
frontend/src/components/DocIndex.jsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { FilePlus2, FileText, MessageSquare, RefreshCw, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
import RoleBadge from './RoleBadge.jsx';
|
||||||
|
import { ROLES, api } from '../lib/api.js';
|
||||||
|
import { slugify } from '../lib/slug.js';
|
||||||
|
import { docHref, editHref, navigate } from '../lib/router.js';
|
||||||
|
|
||||||
|
const STARTER = `# New Application Guide
|
||||||
|
|
||||||
|
Describe the application, then add a section for each audience:
|
||||||
|
|
||||||
|
## For Users
|
||||||
|
What a day-to-day user needs.
|
||||||
|
|
||||||
|
## For Operators
|
||||||
|
Deployment, health checks and rollback.
|
||||||
|
|
||||||
|
## For Developers
|
||||||
|
API contracts and integration notes.
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Index page: browse, search and create the flat-file Markdown documents. */
|
||||||
|
export default function DocIndex() {
|
||||||
|
const [docs, setDocs] = useState([]);
|
||||||
|
const [commentsByDoc, setCommentsByDoc] = useState({});
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [draft, setDraft] = useState(STARTER);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload = await api.listDocsDetailed();
|
||||||
|
setDocs(payload.docs ?? []);
|
||||||
|
const tally = {};
|
||||||
|
for (const doc of payload.docs ?? []) tally[doc.slug] = doc.comments ?? 0;
|
||||||
|
setCommentsByDoc(tally);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (!needle) return docs;
|
||||||
|
return docs.filter(
|
||||||
|
(doc) =>
|
||||||
|
doc.slug.toLowerCase().includes(needle) || doc.title.toLowerCase().includes(needle),
|
||||||
|
);
|
||||||
|
}, [docs, query]);
|
||||||
|
|
||||||
|
const previewSlug = useMemo(() => slugify(newTitle), [newTitle]);
|
||||||
|
|
||||||
|
async function create(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const slug = slugify(newTitle);
|
||||||
|
if (!slug) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await api.saveDoc(slug, draft, newTitle.trim());
|
||||||
|
setNewTitle('');
|
||||||
|
setDraft(STARTER);
|
||||||
|
await load();
|
||||||
|
navigate(docHref(slug));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalComments = docs.reduce((sum, doc) => sum + (doc.comments ?? 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-6 py-10">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight text-white">Documentation</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-400">
|
||||||
|
Flat-file Markdown served straight from <code className="font-mono text-ink-300">data/docs/</code>.
|
||||||
|
Every page carries guidance for three isolated audiences, each with its own feedback badge.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative min-w-64 flex-1">
|
||||||
|
<Search
|
||||||
|
size={15}
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Search documents…"
|
||||||
|
aria-label="Search documents"
|
||||||
|
className="w-full rounded-lg border border-ink-700 bg-ink-850 py-2 pl-9 pr-3 text-sm text-ink-200 outline-none transition focus:border-ink-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-ink-700 px-3 py-2 text-xs font-semibold text-ink-300 transition hover:bg-ink-800 hover:text-ink-100"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} aria-hidden="true" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-xs text-ink-500">
|
||||||
|
{docs.length} docs · {totalComments} comments
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="mb-6 rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading && docs.length === 0 ? (
|
||||||
|
<p className="py-16 text-center text-sm text-ink-400">Indexing data/docs…</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed border-ink-700 py-16 text-center text-sm text-ink-400">
|
||||||
|
No documents match “{query}”.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
|
{filtered.map((doc) => (
|
||||||
|
<li
|
||||||
|
key={doc.slug}
|
||||||
|
className="group rounded-xl border border-ink-700 bg-ink-850 p-4 transition hover:border-ink-600"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(docHref(doc.slug))}
|
||||||
|
className="block w-full text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText size={17} className="mt-0.5 shrink-0 text-ink-500" aria-hidden="true" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="truncate text-sm font-semibold text-ink-100 transition group-hover:text-white">
|
||||||
|
{doc.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 truncate font-mono text-[11px] text-ink-500">{doc.slug}.md</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-[11px] text-ink-500">
|
||||||
|
<span>{doc.size} bytes</span>
|
||||||
|
<span>{new Date(doc.modified).toLocaleDateString()}</span>
|
||||||
|
<span className="ml-auto inline-flex items-center gap-1">
|
||||||
|
<MessageSquare size={11} aria-hidden="true" />
|
||||||
|
{commentsByDoc[doc.slug] ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="mt-3 flex gap-2 border-t border-ink-700 pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(docHref(doc.slug))}
|
||||||
|
className="flex-1 rounded-md border border-ink-700 py-1.5 text-[11px] font-medium text-ink-300 transition hover:bg-ink-800 hover:text-white"
|
||||||
|
>
|
||||||
|
Read
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(editHref(doc.slug))}
|
||||||
|
className="flex-1 rounded-md border border-ink-700 py-1.5 text-[11px] font-medium text-ink-300 transition hover:bg-ink-800 hover:text-white"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
frontend/src/components/DocViewer.jsx
Normal file
203
frontend/src/components/DocViewer.jsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Link2,
|
||||||
|
MessageSquare,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import CommentPanel from './CommentPanel.jsx';
|
||||||
|
import RoleBadge from './RoleBadge.jsx';
|
||||||
|
import { ROLES, api } from '../lib/api.js';
|
||||||
|
import { markdownToHtml } from '../lib/markdown.js';
|
||||||
|
import { markdownForRole, segmentByAudience } from '../lib/audience.js';
|
||||||
|
import { docHref, editHref, navigate, resolveDocLink } from '../lib/router.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DocViewer is the audience-segmented reader. One Markdown file is sliced into
|
||||||
|
* per-role sections, so switching the tab swaps the guidance without ever
|
||||||
|
* duplicating content across files.
|
||||||
|
*/
|
||||||
|
export default function DocViewer({ slug, onChanged }) {
|
||||||
|
const [doc, setDoc] = useState(null);
|
||||||
|
const [comments, setComments] = useState([]);
|
||||||
|
const [audience, setAudience] = useState('user');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [docPayload, commentPayload] = await Promise.all([
|
||||||
|
api.getDoc(slug),
|
||||||
|
api.listComments(slug),
|
||||||
|
]);
|
||||||
|
setDoc(docPayload);
|
||||||
|
setComments(commentPayload.comments ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
setDoc(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
const segments = useMemo(() => (doc ? segmentByAudience(doc.content) : null), [doc]);
|
||||||
|
const bodyHtml = useMemo(
|
||||||
|
() => (doc ? markdownToHtml(markdownForRole(doc.content, audience)) : ''),
|
||||||
|
[audience, doc],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleLinkClick(event) {
|
||||||
|
const anchor = event.target.closest('a');
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
const href = anchor.getAttribute('href');
|
||||||
|
if (!href) return;
|
||||||
|
|
||||||
|
const targetSlug = resolveDocLink(href, doc.slug);
|
||||||
|
if (targetSlug) {
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(docHref(targetSlug));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid place-items-center py-32 text-sm text-ink-400">
|
||||||
|
<span className="animate-pulse">Loading {slug}…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !doc) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl py-24 text-center">
|
||||||
|
<AlertCircle className="mx-auto text-red-400" size={32} aria-hidden="true" />
|
||||||
|
<h1 className="mt-4 text-lg font-semibold text-white">Document not available</h1>
|
||||||
|
<p className="mt-2 text-sm text-ink-400">
|
||||||
|
{error?.message ?? `No Markdown file is stored for “${slug}”.`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-ink-700 px-4 py-2 text-sm font-medium text-white transition hover:bg-ink-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={15} aria-hidden="true" />
|
||||||
|
Back to index
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!window.confirm(`Permanently delete “${doc.slug}” and its feedback?`)) return;
|
||||||
|
try {
|
||||||
|
await api.deleteDoc(doc.slug);
|
||||||
|
onChanged?.();
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMeta = ROLES.find((r) => r.role === audience);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid max-w-[1400px] gap-6 px-6 py-8 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
|
<article className="min-w-0" onClick={handleLinkClick}>
|
||||||
|
<nav className="mb-5 flex items-center gap-2 text-xs text-ink-400">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="inline-flex items-center gap-1 transition hover:text-ink-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={13} aria-hidden="true" />
|
||||||
|
All documents
|
||||||
|
</button>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<span className="font-mono text-ink-300">{doc.slug}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header className="mb-5 rounded-xl border border-ink-700 bg-ink-850 p-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-white">{doc.title}</h1>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-ink-400">
|
||||||
|
<span className="inline-flex items-center gap-1.5 font-mono">
|
||||||
|
<FileText size={13} aria-hidden="true" />
|
||||||
|
{doc.size} bytes
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Clock size={13} aria-hidden="true" />
|
||||||
|
{new Date(doc.modified).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<MessageSquare size={13} aria-hidden="true" />
|
||||||
|
{comments.length} comments
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(editHref(doc.slug))}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-ink-600 px-3 py-2 text-xs font-semibold text-ink-200 transition hover:bg-ink-800"
|
||||||
|
>
|
||||||
|
<Pencil size={14} aria-hidden="true" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard?.writeText(`${window.location.origin}${docHref(doc.slug)}`);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-ink-600 px-3 py-2 text-xs font-semibold text-ink-200 transition hover:bg-ink-800"
|
||||||
|
>
|
||||||
|
<Link2 size={14} aria-hidden="true" />
|
||||||
|
Link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={remove}
|
||||||
|
aria-label="Delete document"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-ink-700 px-3 py-2 text-xs font-semibold text-ink-400 transition hover:border-red-500/40 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{activeMeta ? (
|
||||||
|
<p className="mt-3 text-xs text-ink-400">
|
||||||
|
Showing the {activeMeta.label.toLowerCase()} view — {activeMeta.description.toLowerCase()}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="md-body rounded-xl border border-ink-700 bg-ink-850 px-7 py-6"
|
||||||
|
// Sanitised in markdownToHtml before it reaches the DOM.
|
||||||
|
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside className="min-w-0 xl:sticky xl:top-6 xl:h-[calc(100vh-3rem)]">
|
||||||
|
<CommentPanel slug={doc.slug} comments={comments} onRefresh={load} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/components/RoleBadge.jsx
Normal file
25
frontend/src/components/RoleBadge.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { roleMeta } from '../lib/api.js';
|
||||||
|
|
||||||
|
/** The colour-coded audience marker: User 🟢, Operator 🔵, Developer 🔴. */
|
||||||
|
export default function RoleBadge({ role, size = 'md', showLabel = true }) {
|
||||||
|
const meta = roleMeta(role);
|
||||||
|
const dimensions = size === 'sm' ? 'text-[11px] px-1.5 py-0.5 gap-1' : 'text-xs px-2 py-1 gap-1.5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border font-medium ${dimensions}`}
|
||||||
|
style={{
|
||||||
|
color: meta.hex,
|
||||||
|
borderColor: `${meta.hex}55`,
|
||||||
|
backgroundColor: `${meta.hex}14`,
|
||||||
|
}}
|
||||||
|
title={meta.label}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[13px] leading-none">
|
||||||
|
{meta.badge}
|
||||||
|
</span>
|
||||||
|
{showLabel ? <span>{meta.label}</span> : null}
|
||||||
|
<span className="sr-only">{meta.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
frontend/src/index.css
Normal file
166
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-ink-950: #070a0e;
|
||||||
|
--color-ink-900: #0b0f14;
|
||||||
|
--color-ink-850: #10151c;
|
||||||
|
--color-ink-800: #151b24;
|
||||||
|
--color-ink-700: #1e2632;
|
||||||
|
--color-ink-600: #2b3543;
|
||||||
|
--color-ink-400: #6b7a90;
|
||||||
|
--color-ink-300: #9aa8bd;
|
||||||
|
--color-ink-200: #cbd5e1;
|
||||||
|
--color-user: #22c55e;
|
||||||
|
--color-operator: #3b82f6;
|
||||||
|
--color-developer: #ef4444;
|
||||||
|
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-ink-900 text-ink-200 font-sans antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
@apply bg-ink-600 text-ink-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thin, unobtrusive scrollbars for the long markdown panes. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-ink-600) transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Rendered markdown surface, shared by the viewer, the editor preview and the
|
||||||
|
WYSIWYG surface so all three look identical. */
|
||||||
|
.md-body {
|
||||||
|
@apply text-[15px] leading-7 text-ink-200;
|
||||||
|
}
|
||||||
|
.md-body > * + * {
|
||||||
|
@apply mt-5;
|
||||||
|
}
|
||||||
|
.md-body h1 {
|
||||||
|
@apply mt-8 border-b border-ink-700 pb-2 text-2xl font-semibold tracking-tight text-white;
|
||||||
|
}
|
||||||
|
.md-body h2 {
|
||||||
|
@apply mt-8 border-b border-ink-800 pb-2 text-xl font-semibold tracking-tight text-white;
|
||||||
|
}
|
||||||
|
.md-body h3 {
|
||||||
|
@apply mt-6 text-lg font-semibold text-white;
|
||||||
|
}
|
||||||
|
.md-body h4,
|
||||||
|
.md-body h5,
|
||||||
|
.md-body h6 {
|
||||||
|
@apply mt-5 text-base font-semibold text-ink-200;
|
||||||
|
}
|
||||||
|
.md-body p {
|
||||||
|
@apply leading-7;
|
||||||
|
}
|
||||||
|
.md-body a {
|
||||||
|
@apply text-sky-400 underline decoration-sky-400/40 underline-offset-2 hover:decoration-sky-400;
|
||||||
|
}
|
||||||
|
.md-body ul {
|
||||||
|
@apply list-disc space-y-2 pl-6 marker:text-ink-400;
|
||||||
|
}
|
||||||
|
.md-body ol {
|
||||||
|
@apply list-decimal space-y-2 pl-6 marker:text-ink-400;
|
||||||
|
}
|
||||||
|
.md-body blockquote {
|
||||||
|
@apply border-l-2 border-ink-600 pl-4 text-ink-300 italic;
|
||||||
|
}
|
||||||
|
.md-body code {
|
||||||
|
@apply rounded border border-ink-700 bg-ink-850 px-1.5 py-0.5 font-mono text-[13px] text-emerald-300;
|
||||||
|
}
|
||||||
|
.md-body pre {
|
||||||
|
@apply overflow-x-auto rounded-lg border border-ink-700 bg-ink-950 p-4 text-[13px] leading-6;
|
||||||
|
}
|
||||||
|
.md-body pre code {
|
||||||
|
@apply border-0 bg-transparent p-0 text-ink-200;
|
||||||
|
}
|
||||||
|
.md-body hr {
|
||||||
|
@apply border-ink-700;
|
||||||
|
}
|
||||||
|
.md-body table {
|
||||||
|
@apply w-full border-collapse text-left text-sm;
|
||||||
|
}
|
||||||
|
.md-body th,
|
||||||
|
.md-body td {
|
||||||
|
@apply border border-ink-700 px-3 py-2;
|
||||||
|
}
|
||||||
|
.md-body th {
|
||||||
|
@apply bg-ink-850 font-semibold text-ink-200;
|
||||||
|
}
|
||||||
|
.md-body img {
|
||||||
|
@apply rounded-lg border border-ink-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The WYSIWYG surface is a live region, so it must not inherit the readonly
|
||||||
|
styling of the preview pane. */
|
||||||
|
.md-body[contenteditable='true'] {
|
||||||
|
@apply min-h-full outline-none;
|
||||||
|
}
|
||||||
|
.md-body[contenteditable='true']:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
@apply text-ink-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Account menu component */
|
||||||
|
.account {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.account-trigger {
|
||||||
|
@apply flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-ink-200 transition hover:bg-ink-800;
|
||||||
|
}
|
||||||
|
.account-trigger:focus-visible {
|
||||||
|
@apply outline-none ring-2 ring-sky-500;
|
||||||
|
}
|
||||||
|
.account-avatar-slot {
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
}
|
||||||
|
.account-caret {
|
||||||
|
@apply text-[10px] text-ink-400 transition;
|
||||||
|
}
|
||||||
|
.account[aria-expanded='true'] .account-caret {
|
||||||
|
@apply rotate-180;
|
||||||
|
}
|
||||||
|
.account-menu {
|
||||||
|
@apply absolute right-0 mt-2 min-w-[220px] rounded-lg border border-ink-700 bg-ink-850 py-2 shadow-lg z-50;
|
||||||
|
}
|
||||||
|
.account-identity {
|
||||||
|
@apply px-3 py-2 border-b border-ink-700;
|
||||||
|
}
|
||||||
|
.account-display-name {
|
||||||
|
@apply block text-sm font-medium text-white;
|
||||||
|
}
|
||||||
|
.account-email {
|
||||||
|
@apply block text-xs text-ink-400 truncate;
|
||||||
|
}
|
||||||
|
.account-tenant {
|
||||||
|
@apply block text-xs text-sky-400 truncate;
|
||||||
|
}
|
||||||
|
.account-menu a[role='menuitem'],
|
||||||
|
.account-menu button[role='menuitem'] {
|
||||||
|
@apply block w-full px-3 py-2 text-sm text-ink-200 transition hover:bg-ink-800 text-left;
|
||||||
|
}
|
||||||
|
.account-menu button[role='menuitem'] {
|
||||||
|
@apply bg-transparent border-0 cursor-pointer font-sans;
|
||||||
|
}
|
||||||
|
.account-note {
|
||||||
|
@apply px-3 py-2 text-xs text-ink-400 border-t border-ink-700;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
@apply rounded-full bg-ink-700;
|
||||||
|
}
|
||||||
|
.avatar-empty {
|
||||||
|
@apply inline-flex items-center justify-center rounded-full bg-sky-500 text-ink-950 font-medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
116
frontend/src/lib/account.js
Normal file
116
frontend/src/lib/account.js
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
export function initials(name) {
|
||||||
|
return String(name || '?')
|
||||||
|
.split(/[\s@._-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0].toUpperCase())
|
||||||
|
.join('') || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
const APOSTROPHE_ENTITY = '&' + '#39;';
|
||||||
|
|
||||||
|
export function escapeHTML(value) {
|
||||||
|
const s = String(value ?? '');
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const ch = s[i];
|
||||||
|
switch (ch) {
|
||||||
|
case '&': out += '&'; break;
|
||||||
|
case '<': out += '<'; break;
|
||||||
|
case '>': out += '>'; break;
|
||||||
|
case '"': out += '"'; break;
|
||||||
|
case '\x27': out += APOSTROPHE_ENTITY; break;
|
||||||
|
default: out += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
const response = await fetch('/api/auth/profile', {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(host) {
|
||||||
|
if (!host || host.dataset.mounted === 'true') return;
|
||||||
|
host.dataset.mounted = 'true';
|
||||||
|
host.classList.add('account');
|
||||||
|
host.innerHTML = `
|
||||||
|
<button type="button" class="account-trigger" aria-haspopup="true" aria-expanded="false" hidden>
|
||||||
|
<span class="account-avatar-slot"></span>
|
||||||
|
<span class="account-name"></span>
|
||||||
|
<span class="account-caret" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="account-menu" role="menu" hidden>
|
||||||
|
<div class="account-identity">
|
||||||
|
<strong class="account-display-name"></strong>
|
||||||
|
<span class="account-email"></span>
|
||||||
|
<span class="account-tenant" hidden></span>
|
||||||
|
</div>
|
||||||
|
<a class="account-profile" role="menuitem" href="/profile">Edit profile</a>
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<button type="submit" role="menuitem">Log out</button>
|
||||||
|
</form>
|
||||||
|
<p class="account-note" hidden>Signing out here also ends your sessions in the applications that use this sign-in.</p>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const trigger = host.querySelector('.account-trigger');
|
||||||
|
const menu = host.querySelector('.account-menu');
|
||||||
|
const note = host.querySelector('.account-note');
|
||||||
|
const tenantEl = host.querySelector('.account-tenant');
|
||||||
|
|
||||||
|
const setOpen = (open) => {
|
||||||
|
menu.hidden = !open;
|
||||||
|
trigger.setAttribute('aria-expanded', String(open));
|
||||||
|
};
|
||||||
|
|
||||||
|
trigger.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setOpen(menu.hidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Escape' || menu.hidden) return;
|
||||||
|
setOpen(false);
|
||||||
|
trigger.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', () => setOpen(false), true);
|
||||||
|
|
||||||
|
loadProfile().then((profile) => {
|
||||||
|
if (!profile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = profile.display_name || profile.email || '';
|
||||||
|
host.querySelector('.account-name').textContent = name;
|
||||||
|
host.querySelector('.account-display-name').textContent = name;
|
||||||
|
host.querySelector('.account-email').textContent = profile.email || '';
|
||||||
|
if (profile.tenant_slug) {
|
||||||
|
tenantEl.textContent = profile.tenant_slug;
|
||||||
|
tenantEl.hidden = false;
|
||||||
|
}
|
||||||
|
const slot = host.querySelector('.account-avatar-slot');
|
||||||
|
slot.innerHTML = profile.avatar_url
|
||||||
|
? `<img class="avatar" src="${escapeHTML(profile.avatar_url)}" alt="" width="24" height="24">`
|
||||||
|
: `<span class="avatar avatar-empty">${escapeHTML(initials(name))}</span>`;
|
||||||
|
trigger.hidden = false;
|
||||||
|
|
||||||
|
const usesHostedLogin = (profile.memberships || []).length > 0;
|
||||||
|
note.hidden = !usesHostedLogin;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountAccountMenu() {
|
||||||
|
document.querySelectorAll('[data-account-menu]').forEach(mount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.mountAccountMenu = mountAccountMenu;
|
||||||
|
}
|
||||||
90
frontend/src/lib/api.js
Normal file
90
frontend/src/lib/api.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
const RAW_BASE = import.meta.env.VITE_API_BASE ?? '';
|
||||||
|
const DEFAULT_TIMEOUT = 15000;
|
||||||
|
|
||||||
|
/** API error carrying the HTTP status and the server's structured error code. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message, status, code) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, { method = 'GET', body, headers = {}, signal } = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
||||||
|
if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${RAW_BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 204) return null;
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const code = payload?.error?.code;
|
||||||
|
const message = payload?.error?.message ?? `${response.status} ${response.statusText}`;
|
||||||
|
throw new ApiError(message, response.status, code);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error;
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new ApiError('Request timed out or was cancelled', 0, 'timeout');
|
||||||
|
}
|
||||||
|
throw new ApiError(error.message || 'Network unreachable', 0, 'network');
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
health: () => request('/api/health'),
|
||||||
|
roles: () => request('/api/roles'),
|
||||||
|
listDocs: (query = '') =>
|
||||||
|
request(`/api/docs${query ? `?q=${encodeURIComponent(query)}` : ''}`),
|
||||||
|
listDocsDetailed: () => request('/api/docs?counts=1'),
|
||||||
|
getDoc: (slug) => request(`/api/docs/${encodeURIComponent(slug)}?format=json`),
|
||||||
|
saveDoc: (slug, content, title) => request(`/api/docs/${encodeURIComponent(slug)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { content, title },
|
||||||
|
}),
|
||||||
|
deleteDoc: (slug) => request(`/api/docs/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
|
||||||
|
listComments: (slug) => request(`/api/docs/${encodeURIComponent(slug)}/comments`),
|
||||||
|
createComment: (slug, comment) =>
|
||||||
|
request(`/api/docs/${encodeURIComponent(slug)}/comments`, { method: 'POST', body: comment }),
|
||||||
|
deleteComment: (slug, id) =>
|
||||||
|
request(`/api/docs/${encodeURIComponent(slug)}/comments/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Audience segments mirrored from the Go engine; badges stay authoritative server-side. */
|
||||||
|
export const ROLES = [
|
||||||
|
{ role: 'user', badge: '🟢', label: 'User', hex: '#22c55e', description: 'Day-to-day workflow guidance' },
|
||||||
|
{ role: 'operator', badge: '🔵', label: 'Operator', hex: '#3b82f6', description: 'Deployment, health and rollback' },
|
||||||
|
{ role: 'developer', badge: '🔴', label: 'Developer', hex: '#ef4444', description: 'Implementation and API contracts' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function roleMeta(role) {
|
||||||
|
return ROLES.find((r) => r.role === role) ?? { role, badge: '⚪', label: role, hex: '#94a3b8' };
|
||||||
|
}
|
||||||
85
frontend/src/lib/audience.js
Normal file
85
frontend/src/lib/audience.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* Audience segmentation.
|
||||||
|
*
|
||||||
|
* go-doc documents are plain Markdown, so a single file carries all three
|
||||||
|
* viewpoints at once. This module slices a document into a shared preamble, a
|
||||||
|
* shared remainder, and per-role sections, so the reader can show a User, an
|
||||||
|
* Operator or a Developer exactly the part that concerns them.
|
||||||
|
*
|
||||||
|
* Sections are delimited by level-2 headings. A section is classified by an
|
||||||
|
* explicit marker when present, otherwise by keywords in its heading; anything
|
||||||
|
* unclassified stays in `common` and is shown to every audience.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AUDIENCES = ['user', 'operator', 'developer'];
|
||||||
|
|
||||||
|
const EXPLICIT = /<!--\s*godoc:(user|operator|developer)\s*-->/i;
|
||||||
|
|
||||||
|
const RULES = [
|
||||||
|
{ role: 'user', pattern: /\b(user|users|end[- ]?user|consumer|customer|day[- ]to[- ]day|using|usage|getting started|walkthrough|onboarding)\b/i },
|
||||||
|
{ role: 'operator', pattern: /\b(operator|operators|ops|operations|sre|deploy|deploying|deployment|release|runbook|infra|infrastructure|production|observability|monitoring|alerting|rollback|on[- ]?call|incident|backup|restore|scaling)\b/i },
|
||||||
|
{ role: 'developer', pattern: /\b(developer|developers|dev|api|sdk|code|schema|contract|integration|programming|internals|architecture|reference|endpoints|library|module)\b/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
function classify(heading) {
|
||||||
|
const explicit = EXPLICIT.exec(heading);
|
||||||
|
if (explicit) return explicit[1].toLowerCase();
|
||||||
|
|
||||||
|
const normalized = heading.replace(/^\s*for\s+/i, '').replace(/^\s*the\s+/i, '');
|
||||||
|
for (const rule of RULES) {
|
||||||
|
if (rule.pattern.test(normalized)) return rule.role;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split Markdown into `{ intro, common, user, operator, developer }`.
|
||||||
|
* Each value is the raw Markdown for that slice; `order` records the source
|
||||||
|
* order of every section so the reader can render an outline.
|
||||||
|
*/
|
||||||
|
export function segmentByAudience(markdown) {
|
||||||
|
const source = markdown ?? '';
|
||||||
|
const lines = source.split('\n');
|
||||||
|
|
||||||
|
const parts = { intro: source, common: '', user: '', operator: '', developer: '' };
|
||||||
|
const outline = [];
|
||||||
|
|
||||||
|
let currentRole = null;
|
||||||
|
let buffer = [];
|
||||||
|
let seenFirstSection = false;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
const chunk = buffer.join('\n').trim();
|
||||||
|
buffer = [];
|
||||||
|
if (!chunk) return;
|
||||||
|
|
||||||
|
if (!seenFirstSection) {
|
||||||
|
parts.intro = [parts.intro.trim(), chunk].filter(Boolean).join('\n\n');
|
||||||
|
seenFirstSection = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = currentRole ?? 'common';
|
||||||
|
parts[target] = [parts[target], chunk].filter(Boolean).join('\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^##[ \t]+\S/.test(line) && !/^###/.test(line)) {
|
||||||
|
flush();
|
||||||
|
const heading = line.replace(/^##[ \t]+/, '').trim();
|
||||||
|
currentRole = classify(heading);
|
||||||
|
outline.push({ heading, role: currentRole ?? 'common', line: lines.indexOf(line) });
|
||||||
|
}
|
||||||
|
buffer.push(line);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
|
||||||
|
parts.order = outline;
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markdown assembled for one audience tab: preamble + shared + role sections. */
|
||||||
|
export function markdownForRole(markdown, role) {
|
||||||
|
const parts = segmentByAudience(markdown);
|
||||||
|
if (!role || !AUDIENCES.includes(role)) return markdown;
|
||||||
|
return [parts.intro, parts.common, parts[role]].filter((part) => part && part.trim()).join('\n\n');
|
||||||
|
}
|
||||||
195
frontend/src/lib/markdown.js
Normal file
195
frontend/src/lib/markdown.js
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
marked.setOptions({ gfm: true, breaks: true });
|
||||||
|
|
||||||
|
const PURIFY_CONFIG = {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'p', 'br', 'hr', 'blockquote',
|
||||||
|
'ul', 'ol', 'li',
|
||||||
|
'strong', 'b', 'em', 'i', 'u', 's', 'del', 'mark',
|
||||||
|
'code', 'pre', 'kbd', 'samp',
|
||||||
|
'a', 'img',
|
||||||
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
||||||
|
'span', 'div',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ['href', 'title', 'target', 'rel', 'src', 'alt', 'class', 'start'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Markdown to sanitized HTML. Every rendered surface in the app goes
|
||||||
|
* through here so the viewer, the split preview and the WYSIWYG surface stay
|
||||||
|
* byte-for-byte consistent, and no untrusted markup can reach the DOM.
|
||||||
|
*/
|
||||||
|
export function markdownToHtml(markdown) {
|
||||||
|
if (!markdown) return '';
|
||||||
|
const raw = marked.parse(markdown, { async: false });
|
||||||
|
return DOMPurify.sanitize(raw, PURIFY_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert rendered HTML back into clean Markdown. */
|
||||||
|
export function htmlToMarkdown(html) {
|
||||||
|
if (!html) return '';
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = html;
|
||||||
|
return cleanMarkdown(serializeChildren(template.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeChildren(node) {
|
||||||
|
let out = '';
|
||||||
|
for (const child of node.childNodes) out += serializeNode(child);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNode(node) {
|
||||||
|
switch (node.nodeType) {
|
||||||
|
case Node.TEXT_NODE:
|
||||||
|
return escapeText(node.nodeValue ?? '');
|
||||||
|
case Node.CODELEMENT_NODE:
|
||||||
|
return escapeText(serializeChildren(node));
|
||||||
|
case Node.COMMENT_NODE:
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = node.nodeName.toLowerCase();
|
||||||
|
const inner = serializeChildren(node);
|
||||||
|
|
||||||
|
switch (tag) {
|
||||||
|
case 'h1':
|
||||||
|
case 'h2':
|
||||||
|
case 'h3':
|
||||||
|
case 'h4':
|
||||||
|
case 'h5':
|
||||||
|
case 'h6':
|
||||||
|
return `${tag} ${inner.trim()}\n\n`;
|
||||||
|
case 'p':
|
||||||
|
case 'div':
|
||||||
|
return inner.trim() ? `${inner.trim()}\n\n` : '';
|
||||||
|
case 'br':
|
||||||
|
return ' \n';
|
||||||
|
case 'hr':
|
||||||
|
return '---\n\n';
|
||||||
|
case 'strong':
|
||||||
|
case 'b':
|
||||||
|
return wrap(inner.trim(), '**');
|
||||||
|
case 'em':
|
||||||
|
case 'i':
|
||||||
|
return wrap(inner.trim(), '*');
|
||||||
|
case 'u':
|
||||||
|
return wrap(inner.trim(), '<u>');
|
||||||
|
case 's':
|
||||||
|
case 'del':
|
||||||
|
return wrap(inner.trim(), '~~');
|
||||||
|
case 'mark':
|
||||||
|
return wrap(inner.trim(), '==');
|
||||||
|
case 'code': {
|
||||||
|
if (node.parentElement?.tagName?.toLowerCase() === 'pre') {
|
||||||
|
const language = readLanguage(node) ?? '';
|
||||||
|
return `\n\`\`\`${language}\n${node.textContent.replace(/\n$/, '')}\n\`\`\`\n\n`;
|
||||||
|
}
|
||||||
|
return wrap(inner.trim(), '`');
|
||||||
|
}
|
||||||
|
case 'pre':
|
||||||
|
return serializeChildren(node);
|
||||||
|
case 'a': {
|
||||||
|
const href = node.getAttribute('href') ?? '';
|
||||||
|
const label = inner.trim() || href;
|
||||||
|
return href ? `[${label}](${href})` : label;
|
||||||
|
}
|
||||||
|
case 'img': {
|
||||||
|
const src = node.getAttribute('src') ?? '';
|
||||||
|
const alt = node.getAttribute('alt') ?? '';
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
case 'blockquote': {
|
||||||
|
const quoted = inner.trim().split('\n').map((l) => (l ? `> ${l}` : '>')).join('\n');
|
||||||
|
return `${quoted}\n\n`;
|
||||||
|
}
|
||||||
|
case 'ul':
|
||||||
|
case 'ol':
|
||||||
|
return serializeList(node, tag === 'ol');
|
||||||
|
case 'li':
|
||||||
|
return `${inner.trim()}\n`;
|
||||||
|
case 'table':
|
||||||
|
return serializeTable(node);
|
||||||
|
default:
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLanguage(codeEl) {
|
||||||
|
const className = codeEl.getAttribute('class') ?? '';
|
||||||
|
const match = className.match(/language-([\w+-]+)/);
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeList(listEl, ordered, depth = 0) {
|
||||||
|
const indent = ' '.repeat(depth);
|
||||||
|
let out = '';
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
for (const li of listEl.children) {
|
||||||
|
if (li.tagName?.toLowerCase() !== 'li') continue;
|
||||||
|
index += 1;
|
||||||
|
const marker = ordered ? `${index}. ` : '- ';
|
||||||
|
const own = serializeChildren(li)
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim() !== '')
|
||||||
|
.map((line, i) => (i === 0 ? `${indent}${marker}${line}` : `${indent} ${line}`))
|
||||||
|
.join('\n');
|
||||||
|
out += `${own}\n`;
|
||||||
|
}
|
||||||
|
return `${out}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTable(tableEl) {
|
||||||
|
const rows = Array.from(tableEl.rows ?? []);
|
||||||
|
if (rows.length === 0) return '';
|
||||||
|
const cells = (row) =>
|
||||||
|
Array.from(row.cells ?? []).map((cell) => serializeChildren(cell).replace(/\s+/g, ' ').trim());
|
||||||
|
|
||||||
|
const header = cells(rows[0]);
|
||||||
|
let out = `| ${header.join(' | ')} |\n`;
|
||||||
|
out += `| ${header.map(() => '---').join(' | ')} |\n`;
|
||||||
|
for (const row of rows.slice(1)) {
|
||||||
|
out += `| ${cells(row).join(' | ')} |\n`;
|
||||||
|
}
|
||||||
|
return `${out}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(value, token) {
|
||||||
|
if (!value) return '';
|
||||||
|
if (token === '`') return `\`${value}\``;
|
||||||
|
if (token === '<u>') return `<u>${value}</u>`;
|
||||||
|
return `${token}${value}${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape only what would otherwise change meaning in Markdown output. */
|
||||||
|
function escapeText(text) {
|
||||||
|
return text
|
||||||
|
.replace(/([\\`*_[\]])/g, '\\$1')
|
||||||
|
.replace(/\u00a0/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanMarkdown(markdown) {
|
||||||
|
return markdown
|
||||||
|
.replace(/[ \t]+$/gm, '')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.replace(/^\s+/, '')
|
||||||
|
.replace(/\s+$/, '')
|
||||||
|
.concat('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Word and character counts for the editor status bar. */
|
||||||
|
export function countWords(markdown) {
|
||||||
|
const stripped = (markdown ?? '')
|
||||||
|
.replace(/```[\s\S]*?```/g, ' ')
|
||||||
|
.replace(/[#>*_`~[\]()|-]/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (!stripped) return 0;
|
||||||
|
return stripped.split(/\s+/).length;
|
||||||
|
}
|
||||||
114
frontend/src/lib/router.js
Normal file
114
frontend/src/lib/router.js
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal History API router. go-doc has exactly three routes, so the app ships
|
||||||
|
* without react-router: `/` (index), `/docs/:slug` (reader) and `/docs/:slug/edit`.
|
||||||
|
*/
|
||||||
|
export function parseRoute(pathname) {
|
||||||
|
const path = pathname.replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
|
if (path === '/') return { name: 'index', slug: null, section: 'index' };
|
||||||
|
|
||||||
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
if (segments[0] !== 'docs' || !segments[1]) return { name: 'notfound', slug: null, section: 'notfound' };
|
||||||
|
|
||||||
|
const slug = decodeURIComponent(segments[1]);
|
||||||
|
const section = segments[2] === 'edit' ? 'edit' : 'view';
|
||||||
|
return { name: section, slug, section };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigate(to, { replace = false } = {}) {
|
||||||
|
if (to === window.location.pathname) return;
|
||||||
|
if (replace) window.history.replaceState({}, '', to);
|
||||||
|
else window.history.pushState({}, '', to);
|
||||||
|
notify();
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoute() {
|
||||||
|
const [route, setRoute] = useState(() => parseRoute(window.location.pathname));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setRoute(parseRoute(window.location.pathname));
|
||||||
|
listeners.add(onChange);
|
||||||
|
window.addEventListener('popstate', onChange);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(onChange);
|
||||||
|
window.removeEventListener('popstate', onChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docHref(slug) {
|
||||||
|
return `/docs/${encodeURIComponent(slug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editHref(slug) {
|
||||||
|
return `/docs/${encodeURIComponent(slug)}/edit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNavigate() {
|
||||||
|
return useCallback((to, options) => navigate(to, options), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a link href points to another go-doc document and return the slug.
|
||||||
|
* Returns null if the link is external or not a document reference.
|
||||||
|
* Handles:
|
||||||
|
* - /docs/slug (absolute SPA route)
|
||||||
|
* - slug.md (relative markdown reference)
|
||||||
|
* - docs/slug.md (relative docs folder reference)
|
||||||
|
* - ./slug.md, ../slug.md
|
||||||
|
*/
|
||||||
|
export function resolveDocLink(href, currentSlug) {
|
||||||
|
if (!href) return null;
|
||||||
|
|
||||||
|
const url = new URL(href, 'http://localhost');
|
||||||
|
let path = url.pathname;
|
||||||
|
|
||||||
|
// Strip leading slash for consistent handling
|
||||||
|
if (path.startsWith('/')) path = path.slice(1);
|
||||||
|
|
||||||
|
// Absolute SPA route: /docs/slug
|
||||||
|
if (path.startsWith('docs/')) {
|
||||||
|
const slug = decodeURIComponent(path.slice(5));
|
||||||
|
if (slug && slug !== 'edit') return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative markdown file reference
|
||||||
|
if (path.endsWith('.md')) {
|
||||||
|
let file = path;
|
||||||
|
if (file.startsWith('./')) file = file.slice(2);
|
||||||
|
// Strip any docs/ prefix (including nested like docs/adr/)
|
||||||
|
file = file.replace(/^docs\/[^/]+\//, '').replace(/^docs\//, '');
|
||||||
|
const slug = file.replace(/\.md$/, '');
|
||||||
|
if (slug) {
|
||||||
|
// If we have a current slug, prefix with project name for subdocuments
|
||||||
|
// e.g., currentSlug="go-bananas", link="0001-embedded-sqlite" -> "go-bananas-0001-embedded-sqlite"
|
||||||
|
if (currentSlug) {
|
||||||
|
const projectPrefix = currentSlug.split('-').slice(0, -1).join('-') || currentSlug;
|
||||||
|
return `${projectPrefix}-${slug}`;
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If current slug is provided, try to resolve relative to it
|
||||||
|
if (currentSlug && !path.startsWith('http')) {
|
||||||
|
const slug = path.replace(/\.md$/, '');
|
||||||
|
if (slug) {
|
||||||
|
const projectPrefix = currentSlug.split('-').slice(0, -1).join('-') || currentSlug;
|
||||||
|
return `${projectPrefix}-${slug}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
54
frontend/src/lib/slug.js
Normal file
54
frontend/src/lib/slug.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Client-side mirror of storage.Slugify in the Go engine, used to preview the
|
||||||
|
* filename a new title will produce before the request is sent. The backend
|
||||||
|
* remains authoritative and re-normalises every slug it receives.
|
||||||
|
*/
|
||||||
|
const LATIN_FOLD = {
|
||||||
|
á: 'a', à: 'a', â: 'a', ä: 'a', ã: 'a', å: 'a', ā: 'a', ă: 'a', ą: 'a',
|
||||||
|
ç: 'c', ć: 'c', č: 'c', ĉ: 'c', ċ: 'c',
|
||||||
|
ď: 'd', đ: 'd',
|
||||||
|
é: 'e', è: 'e', ê: 'e', ë: 'e', ē: 'e', ė: 'e', ę: 'e', ě: 'e',
|
||||||
|
ğ: 'g', ĝ: 'g', ġ: 'g', ģ: 'g',
|
||||||
|
í: 'i', ì: 'i', î: 'i', ï: 'i', ī: 'i', į: 'i', ı: 'i',
|
||||||
|
ĵ: 'j', ķ: 'k', ł: 'l', ľ: 'l', ĺ: 'l',
|
||||||
|
ñ: 'n', ń: 'n', ň: 'n',
|
||||||
|
ó: 'o', ò: 'o', ô: 'o', ö: 'o', õ: 'o', ø: 'o', ō: 'o', ő: 'o',
|
||||||
|
ŕ: 'r', ř: 'r',
|
||||||
|
ś: 's', š: 's', ş: 's', ș: 's', ß: 's', þ: 't', ť: 't',
|
||||||
|
ú: 'u', ù: 'u', û: 'u', ü: 'u', ū: 'u', ů: 'u', ű: 'u',
|
||||||
|
ŵ: 'w', ý: 'y', ÿ: 'y', ŷ: 'y',
|
||||||
|
ź: 'z', ż: 'z', ž: 'z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_SLUG_LEN = 120;
|
||||||
|
|
||||||
|
export function slugify(title) {
|
||||||
|
let out = '';
|
||||||
|
let pendingSeparator = true;
|
||||||
|
|
||||||
|
for (const raw of (title ?? '').toLowerCase()) {
|
||||||
|
const char = LATIN_FOLD[raw] ?? raw;
|
||||||
|
if (/[a-z0-9]/.test(char)) {
|
||||||
|
out += char;
|
||||||
|
pendingSeparator = false;
|
||||||
|
} else if (!pendingSeparator) {
|
||||||
|
out += '-';
|
||||||
|
pendingSeparator = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = out.replace(/^-+|-+$/g, '');
|
||||||
|
if ([...slug].length > MAX_SLUG_LEN) {
|
||||||
|
slug = [...slug].slice(0, MAX_SLUG_LEN).join('').replace(/-+$/, '');
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a slug back into a human title: core-service-api -> Core Service Api. */
|
||||||
|
export function titleize(slug) {
|
||||||
|
return (slug ?? '')
|
||||||
|
.split(/[-_]/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
11
frontend/src/main.jsx
Normal file
11
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import App from './App.jsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
29
frontend/vite.config.js
Normal file
29
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
// The dev server proxies /api to the Go backend so the SPA and the API share an
|
||||||
|
// origin during development. CORS stays enabled on the backend for anyone who
|
||||||
|
// prefers to call it cross-origin instead.
|
||||||
|
const BACKEND = process.env.GO_DOC_BACKEND ?? 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: BACKEND,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false,
|
||||||
|
chunkSizeWarningLimit: 900,
|
||||||
|
},
|
||||||
|
});
|
||||||
6
helm/go-doc/Chart.yaml
Normal file
6
helm/go-doc/Chart.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
apiVersion: v2
|
||||||
|
name: go-doc
|
||||||
|
description: Go documentation and feedback platform
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "latest"
|
||||||
53
helm/go-doc/templates/_helpers.tpl
Normal file
53
helm/go-doc/templates/_helpers.tpl
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{{- define "go-doc.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "go-doc.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "go-doc.labels" -}}
|
||||||
|
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
||||||
|
app.kubernetes.io/name: {{ include "go-doc.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "go-doc.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "go-doc.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "go-doc.secretName" -}}
|
||||||
|
{{- printf "%s-secrets" (include "go-doc.fullname" .) }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "go-doc.caVolume" -}}
|
||||||
|
{{- if and .Values.goLoose.enabled .Values.goLoose.ca.existingSecret }}
|
||||||
|
- name: ca-cert
|
||||||
|
secret:
|
||||||
|
secretName: {{ .Values.goLoose.ca.existingSecret }}
|
||||||
|
items:
|
||||||
|
- key: {{ .Values.goLoose.ca.key }}
|
||||||
|
path: {{ base .Values.goLoose.ca.mountPath }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "go-doc.caVolumeMount" -}}
|
||||||
|
{{- if and .Values.goLoose.enabled .Values.goLoose.ca.existingSecret }}
|
||||||
|
- name: ca-cert
|
||||||
|
mountPath: {{ .Values.goLoose.ca.mountPath }}
|
||||||
|
subPath: {{ base .Values.goLoose.ca.mountPath }}
|
||||||
|
readOnly: true
|
||||||
|
{{- end }}
|
||||||
|
{{- end -}}
|
||||||
126
helm/go-doc/templates/deployment.yaml
Normal file
126
helm/go-doc/templates/deployment.yaml
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
{{- $fullName := include "go-doc.fullname" . }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
labels:
|
||||||
|
{{- include "go-doc.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
strategy:
|
||||||
|
type: {{ .Values.strategy.type }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "go-doc.selectorLabels" . | nindent 6 }}
|
||||||
|
app.kubernetes.io/component: application
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
checksum/secrets: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||||
|
{{- with .Values.podAnnotations }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
labels:
|
||||||
|
{{- include "go-doc.selectorLabels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: application
|
||||||
|
{{- with .Values.podLabels }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
|
containers:
|
||||||
|
- name: {{ $fullName }}
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
command:
|
||||||
|
- /godoc
|
||||||
|
- -addr
|
||||||
|
- 0.0.0.0:8080
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||||
|
env:
|
||||||
|
- name: GO_DOC_DATA_DIR
|
||||||
|
value: /data
|
||||||
|
- name: GO_DOC_LOG_FORMAT
|
||||||
|
value: json
|
||||||
|
- name: GO_DOC_LOG_LEVEL
|
||||||
|
value: info
|
||||||
|
{{- if .Values.goLoose.enabled }}
|
||||||
|
- name: GO_LOOSE_TENANT
|
||||||
|
value: {{ .Values.goLoose.tenant | quote }}
|
||||||
|
- name: GO_LOOSE_AUTH_DOMAIN
|
||||||
|
value: {{ .Values.goLoose.authDomain | quote }}
|
||||||
|
- name: GO_LOOSE_APP_DOMAIN
|
||||||
|
value: {{ .Values.goLoose.appDomain | quote }}
|
||||||
|
- name: GO_LOOSE_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "go-doc.secretName" . }}
|
||||||
|
key: go-loose-client-id
|
||||||
|
- name: GO_LOOSE_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "go-doc.secretName" . }}
|
||||||
|
key: go-loose-client-secret
|
||||||
|
{{- if .Values.goLoose.ca.existingSecret }}
|
||||||
|
- name: GO_LOOSE_CA_FILE
|
||||||
|
value: {{ .Values.goLoose.ca.mountPath | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.service.port }}
|
||||||
|
protocol: TCP
|
||||||
|
{{- with .Values.probes.livenessProbe }}
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: {{ .httpGet.path }}
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: {{ .initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .periodSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.probes.readinessProbe }}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: {{ .httpGet.path }}
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: {{ .initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .periodSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data/comments
|
||||||
|
- name: temporary-files
|
||||||
|
mountPath: /tmp
|
||||||
|
{{- include "go-doc.caVolumeMount" . | nindent 12 }}
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "go-doc.fullname" . }}-pvc
|
||||||
|
{{- else }}
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
|
- name: temporary-files
|
||||||
|
emptyDir: {}
|
||||||
|
{{- include "go-doc.caVolume" . | nindent 8 }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
34
helm/go-doc/templates/ingress.yaml
Normal file
34
helm/go-doc/templates/ingress.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
{{- $fullName := include "go-doc.fullname" . }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
labels:
|
||||||
|
{{- include "go-doc.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
17
helm/go-doc/templates/pvc.yaml
Normal file
17
helm/go-doc/templates/pvc.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "go-doc.fullname" . }}-pvc
|
||||||
|
labels:
|
||||||
|
{{- include "go-doc.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.size }}
|
||||||
|
{{- if .Values.persistence.storageClass }}
|
||||||
|
storageClassName: {{ .Values.persistence.storageClass }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
13
helm/go-doc/templates/secret.yaml
Normal file
13
helm/go-doc/templates/secret.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{{- if .Values.goLoose.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "go-doc.secretName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "go-doc.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
sso-token: ""
|
||||||
|
go-loose-client-id: {{ .Values.goLoose.clientID | quote }}
|
||||||
|
go-loose-client-secret: {{ .Values.goLoose.clientSecret | quote }}
|
||||||
|
{{- end }}
|
||||||
16
helm/go-doc/templates/service.yaml
Normal file
16
helm/go-doc/templates/service.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{{- $fullName := include "go-doc.fullname" . }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
labels:
|
||||||
|
{{- include "go-doc.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
{{- include "go-doc.selectorLabels" . | nindent 4 }}
|
||||||
100
helm/go-doc/values-dev.yaml
Normal file
100
helm/go-doc/values-dev.yaml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Development values for go-doc
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: nexus.urpi.be/docker-hosted/go-doc
|
||||||
|
pullPolicy: Always
|
||||||
|
tag: "latest"
|
||||||
|
|
||||||
|
imagePullSecrets: []
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
# go-loose authentication configuration for development
|
||||||
|
goLoose:
|
||||||
|
enabled: true
|
||||||
|
tenant: doc
|
||||||
|
authDomain: auth-dev.urpi.be
|
||||||
|
appDomain: doc-dev.urpi.be
|
||||||
|
ca:
|
||||||
|
existingSecret: "local-ca"
|
||||||
|
key: ca.crt
|
||||||
|
mountPath: /certs/local-ca.crt
|
||||||
|
clientID: ""
|
||||||
|
clientSecret: ""
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
|
||||||
|
probes:
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
hosts:
|
||||||
|
- host: doc-dev.urpi.be
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: go-doc-tls
|
||||||
|
hosts:
|
||||||
|
- doc-dev.urpi.be
|
||||||
|
|
||||||
|
certManager:
|
||||||
|
enabled: true
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-staging
|
||||||
|
kind: ClusterIssuer
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 2Gi
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClass: ""
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
podLabels: {}
|
||||||
|
|
||||||
|
podSecurityContext:
|
||||||
|
fsGroup: 10001
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10001
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
103
helm/go-doc/values-lcl.yaml
Normal file
103
helm/go-doc/values-lcl.yaml
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# Local (LCL) values for go-doc.
|
||||||
|
# Served at https://doc-lcl.intern-urpi.be by the docker TLS edge.
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: localhost:5000/go-doc
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
tag: "0.0.1"
|
||||||
|
|
||||||
|
# Images live in the local registry (localhost:5000), no pull secret needed.
|
||||||
|
imagePullSecrets: []
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
# go-loose authentication configuration for the local environment.
|
||||||
|
# local-ca is the certificate authority used by the docker TLS edge
|
||||||
|
# (dev-infrastructure/certs/local-ca.crt).
|
||||||
|
goLoose:
|
||||||
|
enabled: true
|
||||||
|
tenant: doc
|
||||||
|
authDomain: auth-lcl.intern-urpi.be
|
||||||
|
appDomain: doc-lcl.intern-urpi.be
|
||||||
|
ca:
|
||||||
|
existingSecret: "local-ca"
|
||||||
|
key: ca.crt
|
||||||
|
mountPath: /certs/local-ca.crt
|
||||||
|
clientID: ""
|
||||||
|
clientSecret: ""
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
|
||||||
|
probes:
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: traefik
|
||||||
|
annotations: {}
|
||||||
|
hosts:
|
||||||
|
- host: doc-lcl.intern-urpi.be
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: local-dev-cert
|
||||||
|
hosts:
|
||||||
|
- doc-lcl.intern-urpi.be
|
||||||
|
|
||||||
|
# No cert-manager in the local cluster.
|
||||||
|
certManager:
|
||||||
|
enabled: false
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-staging
|
||||||
|
kind: ClusterIssuer
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 2Gi
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClass: ""
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
podLabels: {}
|
||||||
|
|
||||||
|
podSecurityContext:
|
||||||
|
fsGroup: 10001
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10001
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
101
helm/go-doc/values-prd.yaml
Normal file
101
helm/go-doc/values-prd.yaml
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Production values for go-doc
|
||||||
|
replicaCount: 2
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: nexus.urpi.be/docker-hosted/go-doc
|
||||||
|
pullPolicy: Always
|
||||||
|
tag: "202610071324"
|
||||||
|
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: nexus-registry
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
# go-loose authentication configuration for production
|
||||||
|
goLoose:
|
||||||
|
enabled: true
|
||||||
|
tenant: doc
|
||||||
|
authDomain: auth.urpi.be
|
||||||
|
appDomain: doc.urpi.be
|
||||||
|
ca:
|
||||||
|
existingSecret: ""
|
||||||
|
key: ca.crt
|
||||||
|
mountPath: /certs/local-ca.crt
|
||||||
|
clientID: ""
|
||||||
|
clientSecret: ""
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
|
||||||
|
probes:
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
# nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
hosts:
|
||||||
|
- host: doc.urpi.be
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: go-doc-tls
|
||||||
|
hosts:
|
||||||
|
- doc.urpi.be
|
||||||
|
|
||||||
|
certManager:
|
||||||
|
enabled: true
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
kind: ClusterIssuer
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 10Gi
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClass: ""
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
podLabels: {}
|
||||||
|
|
||||||
|
podSecurityContext:
|
||||||
|
fsGroup: 10001
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10001
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
44
helm/go-doc/values-tst.yaml
Normal file
44
helm/go-doc/values-tst.yaml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# TST values for go-doc
|
||||||
|
nameOverride: ""
|
||||||
|
fullnameOverride: ""
|
||||||
|
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: nexus.urpi.be/docker-hosted/go-doc
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: Always
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
hosts:
|
||||||
|
- host: doc-tst.intern-urpi.be
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: go-doc-tls
|
||||||
|
hosts:
|
||||||
|
- doc-tst.intern-urpi.be
|
||||||
|
|
||||||
|
certManager:
|
||||||
|
enabled: true
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-staging
|
||||||
|
kind: ClusterIssuer
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 300m
|
||||||
|
memory: 256Mi
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
100
helm/go-doc/values.yaml
Normal file
100
helm/go-doc/values.yaml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Default values for go-doc
|
||||||
|
replicaCount: 2
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: nexus.urpi.be/docker-hosted/go-doc
|
||||||
|
pullPolicy: Always
|
||||||
|
tag: "latest"
|
||||||
|
|
||||||
|
imagePullSecrets: []
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
# go-loose authentication configuration
|
||||||
|
goLoose:
|
||||||
|
enabled: true
|
||||||
|
tenant: doc
|
||||||
|
authDomain: auth-dev.urpi.be
|
||||||
|
appDomain: doc-dev.urpi.be
|
||||||
|
ca:
|
||||||
|
existingSecret: ""
|
||||||
|
key: ca.crt
|
||||||
|
mountPath: /certs/local-ca.crt
|
||||||
|
clientID: ""
|
||||||
|
clientSecret: ""
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
|
||||||
|
probes:
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
hosts:
|
||||||
|
- host: doc.urpi.be
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: go-doc-tls
|
||||||
|
hosts:
|
||||||
|
- doc.urpi.be
|
||||||
|
|
||||||
|
certManager:
|
||||||
|
enabled: true
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
kind: ClusterIssuer
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 2Gi
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClass: ""
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
podLabels: {}
|
||||||
|
|
||||||
|
podSecurityContext:
|
||||||
|
fsGroup: 10001
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10001
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
50
prompts/001-master-spec.md
Normal file
50
prompts/001-master-spec.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Go-Doc Platform Master Specification
|
||||||
|
|
||||||
|
This document serves as the unbroken context audit trail for the complete, production-ready Go + React Markdown documentation and user feedback platform, optimized for the Omarchy OS ecosystem.
|
||||||
|
|
||||||
|
## 1. WORKSPACE, ENVIRONMENT, & DIRECTORY ENCAPSULATION
|
||||||
|
- **Base Workspace:** All work must occur exclusively within `/home/dnoulet/go/go-doc`.
|
||||||
|
- **Backend Directory Isolation:** Go source code, routers, storage logic, and handlers must reside in `backend/`. Initialize the Go module there (`go mod init go-doc` inside `backend/`).
|
||||||
|
- **Frontend Directory Isolation:** The React application must be scaffolded in `frontend/` using Vite, Tailwind CSS, and lucide-react.
|
||||||
|
|
||||||
|
## 2. FLAT-FILE STORAGE & SLUGIFICATION ENGINE (`backend/storage/engine.go`)
|
||||||
|
- **Data Storage:** `data/docs/` for raw `.md` files and `data/comments/` for user feedback JSON files (`app-name-comments.json`).
|
||||||
|
- **Slug Engine:** A Go utility must translate arbitrary application titles into lower-case, hyphen-separated strings (e.g., "Core Service API" -> "core-service-api").
|
||||||
|
- **File Handlers:** Implement production-ready `SaveDoc(slug, content)`, `ReadDoc(slug)`, and `DeleteDoc(slug)` using the flat-file system.
|
||||||
|
|
||||||
|
## 3. REST API ROUTING LAYER (`backend/main.go` / `backend/router/router.go`)
|
||||||
|
- **HTTP Framework:** Set up a clean HTTP router (net/http, Chi, or Gin) inside the backend scope with full CORS middleware enabled for the frontend Vite server.
|
||||||
|
- **API Endpoints:**
|
||||||
|
- `GET /api/docs/:slug` -> Returns the raw markdown string.
|
||||||
|
- `POST /api/docs/:slug` -> Creates or completely updates the target markdown file.
|
||||||
|
- `DELETE /api/docs/:slug` -> Permanently removes the target documentation file.
|
||||||
|
- `GET /api/docs/:slug/comments` -> Retrieves the JSON list of user/operator/developer comments.
|
||||||
|
- `POST /api/docs/:slug/comments` -> Appends a new comment struct (ID, Author, Role, Content, Timestamp).
|
||||||
|
|
||||||
|
## 4. DUAL-MODE WYSIWYG & AUDIENCE-SEGMENTED UI (`frontend/src/`)
|
||||||
|
- **Dynamic Slugs:** Routing must allow dynamic paths like `/docs/:slug` querying the backend API.
|
||||||
|
- **Audience Viewer (`DocViewer.jsx`):** A tabbed navigation reader layout separating specific viewpoints for Users, Operators, and Developers.
|
||||||
|
- **Hybrid WYSIWYG Editor (`DocEditor.jsx`):** A rich editing suite with "Visual Edit (WYSIWYG)" and "Source Code (Markdown)" toggle. Visual mode must track structural edits (Bold, Italic, Code, Headers) and translate them seamlessly into clean Markdown; Source Code mode must provide live split preview.
|
||||||
|
|
||||||
|
## 5. MANDATORY MANDATED AUTOMATION & INFRASTRUCTURE LAYER
|
||||||
|
- **`docker-compose.yml`:** Setup local development containers with host-to-container volume mappings for live synchronization.
|
||||||
|
- **`Makefile` (CRITICAL / MANDATORY):** Must provide error-free targets for the system lifecycle:
|
||||||
|
- `make infra-up`: Spin up containers detached.
|
||||||
|
- `make infra-down`: Tear down infrastructure safely.
|
||||||
|
- `make backend-run`: Execute `go run main.go` natively from `backend/`.
|
||||||
|
- `make frontend-run`: Launch the Vite development server.
|
||||||
|
- `make dev`: Concurrently execute backend, frontend, and infrastructure.
|
||||||
|
- `make test`: Run all unit, integration, and BDD tests.
|
||||||
|
- `make build`: Build production React static assets, configure Go backend to serve them natively on `/` (preserving `/api/*`), and compile a portable binary.
|
||||||
|
|
||||||
|
## 6. ROBUST VERIFICATION MATRIX: INTEGRATION & BDD TESTING
|
||||||
|
- **Integration Tests (`backend/tests/integration_test.go`):** E2E API tests verifying actual HTTP requests read/write to the local filesystem without mocks.
|
||||||
|
- **BDD Tests (`backend/tests/bdd/`):** Implementation using structured Go BDD syntax/framework.
|
||||||
|
- **Feature 1:** "Given a new application title, When it is saved, Then it should be slugified and written to a Markdown file."
|
||||||
|
- **Feature 2:** "Given an existing documentation page, When a developer adds a comment with a developer badge, Then it should be appended to the JSON array and rendered with a red visual badge."
|
||||||
|
|
||||||
|
## 7. INITIAL DOCUMENTATION SEED & HIGH-VOLUME PERFORMANCE DATA
|
||||||
|
- **Default Document:** Seed `data/docs/go-doc.md` detailing separate layout workflows for Users, Operators, and Developers.
|
||||||
|
- **Performance Seeding Script (`backend/scripts/seed_perf_data.go`):** Routine to populate the flat-file backend with 100+ multi-size markdown files and flood the engine with thousands of mock comments for stress testing.
|
||||||
|
|
||||||
|
**Execution Directive:** Execute this vibe code deployment end-to-end, delivering robust, production-quality code blocks for both Go and React, ensuring the Makefile is fully wired up.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue