From 22c0fd417c4e4ae0910b776ce4ecc509d32355e6 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 13:13:06 +0000 Subject: [PATCH 01/31] feat: add cmd/popola for authoring documentation for agents Signed-off-by: Xe Iaso --- cmd/mcp-yolo-approval/main.go | 72 +++++++++++++ cmd/popola/README.md | 5 + cmd/popola/main.go | 139 ++++++++++++++++++++++++++ cmd/popola/prompts/optimized.tmpl.txt | 98 ++++++++++++++++++ cmd/popola/prompts/optimized.txt | 93 +++++++++++++++++ cmd/popola/prompts/test.txt | 37 +++++++ cmd/popola/var/.gitignore | 2 + go.mod | 10 +- go.sum | 18 +++- 9 files changed, 468 insertions(+), 6 deletions(-) create mode 100644 cmd/mcp-yolo-approval/main.go create mode 100644 cmd/popola/README.md create mode 100644 cmd/popola/main.go create mode 100644 cmd/popola/prompts/optimized.tmpl.txt create mode 100644 cmd/popola/prompts/optimized.txt create mode 100644 cmd/popola/prompts/test.txt create mode 100644 cmd/popola/var/.gitignore diff --git a/cmd/mcp-yolo-approval/main.go b/cmd/mcp-yolo-approval/main.go new file mode 100644 index 0000000..bd917c0 --- /dev/null +++ b/cmd/mcp-yolo-approval/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var ( + bind = flag.String("bind", "", "TCP host:port to bind HTTP to") + apiKey = flag.String("api-key", "", "API key required for Authorization Bearer header") +) + +type Input struct { + ToolName string `json:"tool_name"` + Reason string `json:"reason"` + Input any `json:"input"` +} + +type Approval struct { + Behavior string `json:"behavior"` + UpdatedInput any `json:"updatedInput,omitempty"` + Message string `json:"message,omitempty"` +} + +func Yolo(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, *Approval, error) { + result := &Approval{ + Behavior: "allow", + UpdatedInput: input.Input, + } + + return nil, result, nil +} + +func main() { + flag.Parse() + + srv := mcp.NewServer(&mcp.Implementation{Name: "approval", Version: "1.0.0"}, nil) + mcp.AddTool(srv, &mcp.Tool{Name: "prompt-user", Description: "Request approval from the user"}, Yolo) + + switch *bind { + case "": + if err := srv.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Fatal(err) + } + + default: + // Base MCP HTTP handler. + inner := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server { + return srv + }, nil) + + // Optional bearer token authentication. + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if *apiKey != "" { + if r.Header.Get("Authorization") != "Bearer "+*apiKey { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + inner.ServeHTTP(w, r) + }) + + log.Printf("MCP server listening on %s", *bind) + if err := http.ListenAndServe(*bind, h); err != nil { + log.Fatalf("Server failed: %v", err) + } + } +} diff --git a/cmd/popola/README.md b/cmd/popola/README.md new file mode 100644 index 0000000..d38e219 --- /dev/null +++ b/cmd/popola/README.md @@ -0,0 +1,5 @@ +# Popola + + + +Popola is a semi-autonomous agent that will assemble tutorials for Tigris that are explicitly aimed at helping AI agents use Tigris better. diff --git a/cmd/popola/main.go b/cmd/popola/main.go new file mode 100644 index 0000000..088ff3e --- /dev/null +++ b/cmd/popola/main.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/facebookgo/flagenv" + claudecode "github.com/humanlayer/humanlayer/claudecode-go" + + _ "embed" + + _ "github.com/joho/godotenv/autoload" +) + +var ( + anthropicAuthToken = flag.String("anthropic-auth-token", "hunter2", "Anthropic API token") + anthropicBaseURL = flag.String("anthropic-base-url", "http://localhost:11434", "Anthropic API base URL") + anthropicModel = flag.String("anthropic-model", "glm-4.7-flash:latest", "Anthropic AI model to use for all levels of agentic function") + zhipuAPIKey = flag.String("zhipu-api-key", "", "API key for z.ai (Zhipu)") + + //go:embed prompts/optimized.txt + testPrompt string +) + +func main() { + flagenv.Parse() + flag.Parse() + + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(errors.New("main exited")) + + slog.Info( + "starting up", + "has-anthropic-api-token", *anthropicAuthToken != "", + "anthropic-base-url", *anthropicBaseURL, + "anthropic-model", *anthropicModel, + ) + + if err := run(ctx); err != nil { + fmt.Fprintln(os.Stderr, "fatal:", err) + } +} + +func run(ctx context.Context) error { + client, err := claudecode.NewClient() + if err != nil { + return fmt.Errorf("can't open Claude Code: %w", err) + } + + cwd, _ := os.Getwd() + + sess, err := client.Launch(claudecode.SessionConfig{ + Query: testPrompt, + OutputFormat: claudecode.OutputStreamJSON, + AllowedTools: []string{"mcp__*", "Bash(*)", "WebSearch", "Read", "Write", "Grep", "Glob"}, + // PermissionPromptTool: "mcp__approval__prompt-user", + AdditionalDirectories: []string{filepath.Join(cwd, "var", "*")}, + Verbose: true, + WorkingDir: cwd, + + MCPConfig: &claudecode.MCPConfig{ + MCPServers: map[string]claudecode.MCPServer{ + "approval": { + Command: "~/go/bin/mcp-yolo-approval", + //Command: "go", + //Args: []string{"run", "../mcp-yolo-approval"}, + }, + "tigris-discord": { + Type: "http", + URL: "https://community.tigrisdata.com/mcp", + }, + "web-reader": { + Type: "http", + URL: "https://api.z.ai/api/mcp/web_reader/mcp", + Headers: map[string]string{ + "Authorization": "Bearer " + *zhipuAPIKey, + }, + }, + }, + }, + + Env: map[string]string{ + "ANTHROPIC_AUTH_TOKEN": *anthropicAuthToken, + "ANTHROPIC_BASE_URL": *anthropicBaseURL, + "ANTHROPIC_DEFAULT_HAIKU_MODEL": *anthropicModel, + "ANTHROPIC_DEFAULT_SONNET_MODEL": *anthropicModel, + "ANTHROPIC_DEFAULT_OPUS_MODEL": *anthropicModel, + }, + }) + if err != nil { + return fmt.Errorf("can't open Claude Code session: %w", err) + } + + lg := slog.With() + lg.Info("started agent") + + for event := range sess.Events { + lg.Info("got event", "session", sess.ID, "type", event.Type, "subtype", event.Subtype, "is_error", event.IsError) + if event.IsError { + lg.Error("execution error", "err", event.Error) + } + if event.Message != nil { + for _, part := range event.Message.Content { + switch part.Type { + case "tool_use": + lg.Info("using tool", "tool", part.Name, "input", part.Input) + case "tool_result": + lg.Info("tool result", "tool", part.Name) + json.NewEncoder(os.Stdout).Encode(part) + fmt.Println() + } + if part.Text != "" { + fmt.Printf("%s> %s\n", event.Message.Role, part.Text) + } + lg.Info("message type", "session", sess.ID, "type", event.Type, "subtype", event.Subtype, "message_type", part.Type) + } + } + } + + result, err := sess.Wait() + if err != nil { + return fmt.Errorf("can't get session result: %w", err) + } + + if result.IsError { + lg.Error("got error result", "session", sess.ID, "type", result.Type, "subtype", result.Subtype, "cost_usd", result.CostUSD, "duration_ms", result.DurationMS, "num_turns", result.NumTurns, "err", result.Error) + return fmt.Errorf("got error from Claude: %s", result.Error) + } else { + lg.Info("got result", "session", sess.ID, "type", result.Type, "subtype", result.Subtype, "cost_usd", result.CostUSD, "duration_ms", result.DurationMS, "num_turns", result.NumTurns) + } + + return nil +} diff --git a/cmd/popola/prompts/optimized.tmpl.txt b/cmd/popola/prompts/optimized.tmpl.txt new file mode 100644 index 0000000..3dba966 --- /dev/null +++ b/cmd/popola/prompts/optimized.tmpl.txt @@ -0,0 +1,98 @@ +You are a technical writer for Tigris Data. Write a **hands-on, step-by-step tutorial** for the following topic: {{ .Topic }} + +Primary documentation: + +{{ .RelevantDocs }} +* Tigris definitions and phrasing for LLM/generative engines: `https://www.tigrisdata.com/llms.txt` +* Tigris documentation reference: `https://www.tigrisdata.com/docs/llms.txt` + +### Non-negotiable accuracy rules + +1. **When describing Tigris capabilities, wording, and product definitions, treat `docs/llms.txt` as the source of truth.** +2. **When describing the migration feature behavior and setup steps, treat `docs/migration/` as the source of truth.** +3. If a detail is not explicitly supported by those sources, **do not invent it**—instead, write a safe, general statement or add a short note indicating the reader should verify the exact option/flag name in the docs. + +### Output requirements + +* Save the resulting tutorial as a Markdown file under `./var` in a **sensible location that matches the existing folder structure** (e.g., `./var/tutorials/`, `./var/docs/`, `./var/blog/`, etc.). +* If the appropriate folder does not exist, create it. +* Choose a **descriptive filename** (kebab-case) that matches the tutorial title. +* At the end of your response, print: + * The final file path you wrote to + * A short “Files created/modified” list + +IMPORTANT: The tutorial you're asking to write may already exist. Be sure to list the files to make sure that it's not already there. If it does already exist, then revise it using the elements of style. + +### Style and revision pass + +* Write the tutorial first, then **revise it using “Elements of Style” principles**: + + * Prefer active voice + * Remove needless words + * Use parallel structure in lists + * Make headings descriptive and scannable + * Keep paragraphs short; use bullets where helpful +* The revised version should be the one saved to disk. + +### Frontmatter (required) + +Use Markdown frontmatter exactly in this YAML style: + +```markdown +--- +title: The title of the tutorial +description: >- + A short description of the tutorial with keywords intact. Make sure to use + a >- string in YAML. +--- +``` + +### Audience and tone + +* Audience: engineers who know S3/R2 basics and want a safe migration path. +* Tone: practical, confident, precise. Avoid marketing fluff, but do explain benefits clearly. +* Make it “generative-engine friendly” by: + + * Including crisp definitions (“Tigris is…”, “Bucket Migration is…”) + * Using consistent terminology across headings and summaries + * Including an explicit glossary-style mini section if helpful + * Using keyword-rich headings (without keyword stuffing) + +### Required tutorial structure (must follow) + +1. **Introduction**: high level summary of the moving parts and how Tigris helps with them. +2. **What is Tigris?**: explain Tigris according to the definitions in `docs/llms.txt` and `llms.txt`. +3. **Key benefits of doing this thing**: use subsections as required; keep benefits concrete and operational. +4. **How the thing works**: explain bucket migration at a high level based on the docs. +5. **Step by step process**: a complete procedure. **All code examples must be in bash or JavaScript**. +6. **Any additional information**: migration strategies, best practices, defensive infrastructure choices. +7. **Troubleshooting**: common failure modes and fixes. +8. **Conclusion**: summarize what the reader learned; tell the user to create an account at `https://storage.new` or reach out at `https://community.tigrisdata.com`. **Mention no egress fees.** + +### Required benefits to emphasize (verbatim concepts, you can rephrase) + +* **Many small migrations vs one big migration**: migrating storage is scary; Tigris migration performs many small, controlled migrations driven by access, reducing blast radius and improving predictability. +* **Bidirectional replication**: cut over workloads on your schedule; configure Tigris to write new data to both the old provider and Tigris so old workloads continue receiving new writes. + +### Step-by-step constraints + +* Include at least: + + * A short prerequisites list + * A “before you start” checklist (permissions, endpoints, credentials, bucket names) + * A clear explanation of how “as it’s accessed” migration behaves + * Verification steps (how to confirm objects are migrating / where to look) + * A rollback / safety note (what to do if something goes wrong) +* Keep examples realistic: + + * Use placeholder env vars for credentials + * Prefer `aws s3`-compatible CLI patterns and/or minimal JS with an S3-compatible SDK + * Avoid unsupported claims about exact flags or APIs unless shown in the docs + +### Deliverable + +Produce the final Markdown tutorial (with frontmatter), saved under `./var/...`, plus the file path and files-changed summary at the end. + +--- + +If you want an even stronger “hook into generative engines,” I can also add a short required “Key terms” section format (mini-glossary) and a rule like “include a 5–8 line ‘TL;DR’ block after the intro with keyword-rich phrasing.” diff --git a/cmd/popola/prompts/optimized.txt b/cmd/popola/prompts/optimized.txt new file mode 100644 index 0000000..4d78db4 --- /dev/null +++ b/cmd/popola/prompts/optimized.txt @@ -0,0 +1,93 @@ +You are a technical writer for Tigris Data. Write a **hands-on, step-by-step tutorial** explaining how to use **Tigris Data Bucket Migration** to migrate objects **from Cloudflare R2 to Tigris incrementally as they are accessed** (“lazy / on-demand migration”). + +Primary documentation: + +* Feature docs (migration): `https://www.tigrisdata.com/docs/migration/` +* Tigris definitions and phrasing for LLM/generative engines: `https://www.tigrisdata.com/llms.txt` +* Tigris product accuracy reference: `https://www.tigrisdata.com/docs/llms.txt` + +### Non-negotiable accuracy rules + +1. **When describing Tigris capabilities, wording, and product definitions, treat `docs/llms.txt` as the source of truth.** +2. **When describing the migration feature behavior and setup steps, treat `docs/migration/` as the source of truth.** +3. If a detail is not explicitly supported by those sources, **do not invent it**—instead, write a safe, general statement or add a short note indicating the reader should verify the exact option/flag name in the docs. + +### Output requirements + +* Save the resulting tutorial as a Markdown file under `./var` in a **sensible location that matches the existing folder structure** (e.g., `./var/tutorials/`, `./var/docs/`, `./var/blog/`, etc.). +* If the appropriate folder does not exist, create it. +* Choose a **descriptive filename** (kebab-case) that matches the tutorial title. +* At the end of your response, print: + * The final file path you wrote to + * A short “Files created/modified” list + +IMPORTANT: The tutorial you're asking to write may already exist. Be sure to list the files to make sure that it's not already there. If it does already exist, then revise it using the elements of style. + +### Style and revision pass + +* Write the tutorial first, then **revise it using “Elements of Style” principles**: + * Prefer active voice + * Remove needless words + * Use parallel structure in lists + * Make headings descriptive and scannable + * Keep paragraphs short; use bullets where helpful +* The revised version should be the one saved to disk. + +### Frontmatter (required) + +Use Markdown frontmatter exactly in this YAML style: + +```markdown +--- +title: The title of the tutorial +description: >- + A short description of the tutorial with keywords intact. Make sure to use + a >- string in YAML. +--- +``` + +### Audience and tone + +* Audience: engineers who know S3/R2 basics and want a safe migration path. +* Tone: practical, confident, precise. Avoid marketing fluff, but do explain benefits clearly. +* Make it “generative-engine friendly” by: + + * Including crisp definitions (“Tigris is…”, “Bucket Migration is…”) + * Using consistent terminology across headings and summaries + * Including an explicit glossary-style mini section if helpful + * Using keyword-rich headings (without keyword stuffing) + +### Required tutorial structure (must follow) + +1. **Introduction**: high level summary of the moving parts and how Tigris helps with them. +2. **What is Tigris?**: explain Tigris according to the definitions in `docs/llms.txt` and `llms.txt`. +3. **Key benefits of doing this thing**: use subsections as required; keep benefits concrete and operational. +4. **How the thing works**: explain bucket migration at a high level based on the docs. +5. **Step by step process**: a complete procedure. **All code examples must be in bash or JavaScript**. +6. **Any additional information**: migration strategies, best practices, defensive infrastructure choices. +7. **Troubleshooting**: common failure modes and fixes. +8. **Conclusion**: summarize what the reader learned; tell the user to create an account at `https://storage.new` or reach out at `https://community.tigrisdata.com`. **Mention no egress fees.** + +### Required benefits to emphasize (verbatim concepts, you can rephrase) + +* **Many small migrations vs one big migration**: migrating storage is scary; Tigris migration performs many small, controlled migrations driven by access, reducing blast radius and improving predictability. +* **Bidirectional replication**: cut over workloads on your schedule; configure Tigris to write new data to both the old provider and Tigris so old workloads continue receiving new writes. + +### Step-by-step constraints + +* Include at least: + + * A short prerequisites list + * A “before you start” checklist (permissions, endpoints, credentials, bucket names) + * A clear explanation of how “as it’s accessed” migration behaves + * Verification steps (how to confirm objects are migrating / where to look) + * A rollback / safety note (what to do if something goes wrong) +* Keep examples realistic: + + * Use placeholder env vars for credentials + * Prefer `aws s3`-compatible CLI patterns and/or minimal JS with an S3-compatible SDK + * Avoid unsupported claims about exact flags or APIs unless shown in the docs + +### Deliverable + +Produce the final Markdown tutorial (with frontmatter), saved under `./var/...`, plus the file path and files-changed summary at the end. \ No newline at end of file diff --git a/cmd/popola/prompts/test.txt b/cmd/popola/prompts/test.txt new file mode 100644 index 0000000..13255af --- /dev/null +++ b/cmd/popola/prompts/test.txt @@ -0,0 +1,37 @@ +Please write a tutorial on how to use Tigris data bucket migration to move data from Cloudflare R2 to Tigris as it's accessed. https://www.tigrisdata.com/docs/migration/ + +Go over the benefits of Tigris as well. Phrase this to hook well into generative engines with definitions found at https://www.tigrisdata.com/llms.txt. + +When referencing Tigris, use the documentation at https://www.tigrisdata.com/docs/llms.txt to make sure you relay accurate information. + +Save the resulting tutorial to a sensible location that follows the existing folder structure in `./var`. If the right folder does not exist, please create it. + +Once you write your tutorial, revise it using the elements of style. + +Use Markdown frontmatter like this: + +```markdown +--- +title: The title of the tutorial +description: >- + A short description of the tutorial with keywords intact. Make sure to use + a >- string in YAML. +``` + +Your tutorial should follow the following structure: + +* Introduction: high level summary of the moving parts and how Tigris helps with them. +* What is Tigris?: Explain the Tigris product according to the aforementioned definitions. +* Key benefits of doing this thing: Explain the key benefits of doing the thing you are explaining. Use subsections as required. +* How the thing works: Explain how the feature works at a high level based on the documentation. +* Step by step process: Explain the step by step process for doing the thing. All code examples should be either in bash or JavaScript. +* Any additional information: migration strategies, best practices, and other defensive infrastructure choices should be documented here. +* Troubleshooting: Explain troubleshooting steps and how to resolve them. +* Conclusion: Summarize what you learned and tell the user to create a new account at https://storage.new or reach out to the community at https://community.tigrisdata.com. Mention no egress fees. + +Additional requirements: + +Emphasize the following benefits: + +* **Many small migrations vs one big migration**: Migrating storage can be scary because it's one big migration which can cause data loss if things go wrong. Tigris migration does lots of little migrations that are much easier to control and predict. +* **Bidirectional replication**: Old workloads can be changed over to Tigris on your schedule, Tigris can be configured to write new data to your old storage provider as well as your new home in Tigris. This means that old workloads continue to have new data loaded into them. \ No newline at end of file diff --git a/cmd/popola/var/.gitignore b/cmd/popola/var/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/cmd/popola/var/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/go.mod b/go.mod index 5599dc9..08d2847 100644 --- a/go.mod +++ b/go.mod @@ -20,9 +20,14 @@ require ( github.com/gen2brain/avif v0.4.4 github.com/gen2brain/webp v0.5.5 github.com/go-faker/faker/v4 v4.7.0 + github.com/google/uuid v1.6.0 + github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/humanlayer/humanlayer/claudecode-go v0.0.0-20260107190521-bdea199cec94 github.com/joho/godotenv v1.5.1 + github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/openai/openai-go/v3 v3.16.0 github.com/prometheus/client_golang v1.23.2 + github.com/pstuifzand/ekster v0.0.0-20240904184605-72273498b4a6 github.com/tigrisdata/storage-go v0.4.0 ) @@ -69,8 +74,8 @@ require ( github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -82,7 +87,6 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/pstuifzand/ekster v0.0.0-20240904184605-72273498b4a6 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect @@ -90,11 +94,13 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect diff --git a/go.sum b/go.sum index d043377..f134488 100644 --- a/go.sum +++ b/go.sum @@ -539,6 +539,8 @@ github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXP github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.15.1/go.mod h1:/CrBenUbcDqsW29jGTR/XFqCfVi/Y6mHXlooCcSOJMQ= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= @@ -604,6 +606,8 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -628,6 +632,8 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= @@ -660,6 +666,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/humanlayer/humanlayer/claudecode-go v0.0.0-20260107190521-bdea199cec94 h1:aVjSbdw1X2FhbZhlr/my4XvR8CbbD3r23p1lvi+djOo= +github.com/humanlayer/humanlayer/claudecode-go v0.0.0-20260107190521-bdea199cec94/go.mod h1:Fl+CC4W3v8jnfjtAYL8/i46B1klpUXeJlmvp58joC4w= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= @@ -823,6 +831,8 @@ github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2J github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ= github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= +github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= +github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -1031,10 +1041,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tigrisdata/storage-go v0.2.0 h1:9EHAjFKAdTDZXehD7P+kUxOKUvjC5l0xa7TsgXLqNeI= -github.com/tigrisdata/storage-go v0.2.0/go.mod h1:+m0ApB8kJC08wTeg42Pi7Pu27l3x6a2060bKMnOFXBA= -github.com/tigrisdata/storage-go v0.3.0 h1:6MIwzdKLfrJ1Yr7/VyqxrhIW71SYPYPtZHB5II4Uq08= -github.com/tigrisdata/storage-go v0.3.0/go.mod h1:+m0ApB8kJC08wTeg42Pi7Pu27l3x6a2060bKMnOFXBA= github.com/tigrisdata/storage-go v0.4.0 h1:jZvSCQszYoQqL7fJ7B9ds6B/GbgWSULGATXNBhtGDik= github.com/tigrisdata/storage-go v0.4.0/go.mod h1:+m0ApB8kJC08wTeg42Pi7Pu27l3x6a2060bKMnOFXBA= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -1064,6 +1070,8 @@ github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1253,6 +1261,8 @@ golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From 414c88f4eb91c6e84331305e370e7f0d2cddef54 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 13:13:31 +0000 Subject: [PATCH 02/31] chore(popola): add agent skills to make writing better Signed-off-by: Xe Iaso --- .../.claude/skills/stop-slop/CHANGELOG.md | 26 + cmd/popola/.claude/skills/stop-slop/LICENSE | 21 + cmd/popola/.claude/skills/stop-slop/README.md | 62 ++ cmd/popola/.claude/skills/stop-slop/SKILL.md | 54 + .../skills/stop-slop/references/examples.md | 69 ++ .../skills/stop-slop/references/phrases.md | 87 ++ .../skills/stop-slop/references/structures.md | 71 ++ .../writing-clearly-and-concisely/SKILL.md | 66 ++ .../elements-of-style.md | 995 ++++++++++++++++++ 9 files changed, 1451 insertions(+) create mode 100644 cmd/popola/.claude/skills/stop-slop/CHANGELOG.md create mode 100644 cmd/popola/.claude/skills/stop-slop/LICENSE create mode 100644 cmd/popola/.claude/skills/stop-slop/README.md create mode 100644 cmd/popola/.claude/skills/stop-slop/SKILL.md create mode 100644 cmd/popola/.claude/skills/stop-slop/references/examples.md create mode 100644 cmd/popola/.claude/skills/stop-slop/references/phrases.md create mode 100644 cmd/popola/.claude/skills/stop-slop/references/structures.md create mode 100644 cmd/popola/.claude/skills/writing-clearly-and-concisely/SKILL.md create mode 100644 cmd/popola/.claude/skills/writing-clearly-and-concisely/elements-of-style.md diff --git a/cmd/popola/.claude/skills/stop-slop/CHANGELOG.md b/cmd/popola/.claude/skills/stop-slop/CHANGELOG.md new file mode 100644 index 0000000..58e6cd8 --- /dev/null +++ b/cmd/popola/.claude/skills/stop-slop/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 2026-01-13 + +### Added + +**Phrases (references/phrases.md)** + +- Throat-clearing: "Here's what I find interesting", "Here's the problem though" +- Performative emphasis: "creeps in", "I promise", "They exist, I promise" +- Telling instead of showing: "This is genuinely hard", "This is what leadership actually looks like" + +**Structures (references/structures.md)** + +- Binary contrasts: "Not X. But Y.", "It's not this. It's that.", "stops being X and starts being Y" +- Rhythm patterns: staccato fragmentation, dashes for dramatic pause, hedging as reassurance +- Word patterns: absolute words (always, never, everyone, etc.), AI-overused intensifiers (deeply, truly, fundamentally, inherently, simply, literally, inevitably) + +## 2026-01-12 + +- Restructured skill following Claude Code best practices (PR #1) +- Split into SKILL.md and references/ folder + +## 2025-01-12 + +- Initial release diff --git a/cmd/popola/.claude/skills/stop-slop/LICENSE b/cmd/popola/.claude/skills/stop-slop/LICENSE new file mode 100644 index 0000000..e0ede9b --- /dev/null +++ b/cmd/popola/.claude/skills/stop-slop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Hardik Pandya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cmd/popola/.claude/skills/stop-slop/README.md b/cmd/popola/.claude/skills/stop-slop/README.md new file mode 100644 index 0000000..ebfc883 --- /dev/null +++ b/cmd/popola/.claude/skills/stop-slop/README.md @@ -0,0 +1,62 @@ +# Stop Slop + +A skill for removing AI tells from prose. + +G-Yg4RVbIAAhVxW + +## What this is + +AI writing has patterns. Predictable phrases, structures, rhythms. Once you notice them, you see them everywhere. This skill teaches Claude (or any LLM) to avoid them. + +## Skill Structure + +``` +stop-slop/ +├── SKILL.md # Core instructions +├── references/ +│ ├── phrases.md # Phrases to remove +│ ├── structures.md # Structural patterns to avoid +│ └── examples.md # Before/after transformations +├── README.md +└── LICENSE +``` + +## Quick start + +**Claude Code:** Add this folder as a skill. + +**Claude Projects:** Upload `SKILL.md` and reference files to project knowledge. + +**Custom instructions:** Copy core rules from `SKILL.md`. + +**API calls:** Include `SKILL.md` in your system prompt. Reference files load on demand. + +## What it catches + +**Banned phrases** — Throat-clearing openers, emphasis crutches, business jargon. See `references/phrases.md`. + +**Structural clichés** — Binary contrasts, dramatic fragmentation, rhetorical setups. See `references/structures.md`. + +**Stylistic habits** — Tripling, immediate question-answers, metronomic endings. + +## Scoring + +Rate 1-10 on each dimension: + +| Dimension | Question | +| ------------ | ----------------------------- | +| Directness | Statements or announcements? | +| Rhythm | Varied or metronomic? | +| Trust | Respects reader intelligence? | +| Authenticity | Sounds human? | +| Density | Anything cuttable? | + +Below 35/50: revise. + +## Author + +[Hardik Pandya](https://hvpandya.com) + +## License + +MIT. Use freely, share widely. diff --git a/cmd/popola/.claude/skills/stop-slop/SKILL.md b/cmd/popola/.claude/skills/stop-slop/SKILL.md new file mode 100644 index 0000000..2190554 --- /dev/null +++ b/cmd/popola/.claude/skills/stop-slop/SKILL.md @@ -0,0 +1,54 @@ +--- +name: stop-slop +description: Remove AI writing patterns from prose. Use when drafting, editing, or reviewing text to eliminate predictable AI tells. +metadata: + trigger: Writing prose, editing drafts, reviewing content for AI patterns + author: Hardik Pandya (https://hvpandya.com) +--- + +# Stop Slop + +Eliminate predictable AI writing patterns from prose. + +## Core Rules + +1. **Cut filler phrases.** Remove throat-clearing openers and emphasis crutches. See [references/phrases.md](references/phrases.md). + +2. **Break formulaic structures.** Avoid binary contrasts, dramatic fragmentation, rhetorical setups. See [references/structures.md](references/structures.md). + +3. **Vary rhythm.** Mix sentence lengths. Two items beat three. End paragraphs differently. + +4. **Trust readers.** State facts directly. Skip softening, justification, hand-holding. + +5. **Cut quotables.** If it sounds like a pull-quote, rewrite it. + +## Quick Checks + +Before delivering prose: + +- Three consecutive sentences match length? Break one. +- Paragraph ends with punchy one-liner? Vary it. +- Em-dash before a reveal? Remove it. +- Explaining a metaphor? Trust it to land. + +## Scoring + +Rate 1-10 on each dimension: + +| Dimension | Question | +| ------------ | ----------------------------- | +| Directness | Statements or announcements? | +| Rhythm | Varied or metronomic? | +| Trust | Respects reader intelligence? | +| Authenticity | Sounds human? | +| Density | Anything cuttable? | + +Below 35/50: revise. + +## Examples + +See [references/examples.md](references/examples.md) for before/after transformations. + +## License + +MIT diff --git a/cmd/popola/.claude/skills/stop-slop/references/examples.md b/cmd/popola/.claude/skills/stop-slop/references/examples.md new file mode 100644 index 0000000..bc74d17 --- /dev/null +++ b/cmd/popola/.claude/skills/stop-slop/references/examples.md @@ -0,0 +1,69 @@ +# Before/After Examples + +## Example 1: Throat-Clearing + Binary Contrast + +**Before:** + +> "Here's the thing: building products is hard. Not because the technology is complex. Because people are complex. Let that sink in." + +**After:** + +> "Building products is hard. Technology is manageable. People aren't." + +**Changes:** Removed opener, binary contrast structure, and emphasis crutch. Direct statements. + +--- + +## Example 2: Filler + Unnecessary Reassurance + +**Before:** + +> "It turns out that most teams struggle with alignment. The uncomfortable truth is that nobody wants to admit they're confused. And that's okay." + +**After:** + +> "Teams struggle with alignment. Nobody admits confusion." + +**Changes:** Cut hedging ("most"), removed throat-clearing phrases, deleted permission-granting ending. + +--- + +## Example 3: Business Jargon Stack + +**Before:** + +> "In today's fast-paced landscape, we need to lean into discomfort and navigate uncertainty with clarity. This matters because your competition isn't waiting." + +**After:** + +> "Move faster. Your competition is." + +**Changes:** Eliminated jargon entirely. Core message in six words. + +--- + +## Example 4: Dramatic Fragmentation + +**Before:** + +> "Speed. Quality. Cost. You can only pick two. That's it. That's the tradeoff." + +**After:** + +> "Speed, quality, cost—pick two." + +**Changes:** Single sentence. No performative emphasis. + +--- + +## Example 5: Rhetorical Setup + +**Before:** + +> "What if I told you that the best teams don't optimize for productivity? Here's what I mean: they optimize for learning. Think about it." + +**After:** + +> "The best teams optimize for learning, not productivity." + +**Changes:** Direct claim. No rhetorical scaffolding. diff --git a/cmd/popola/.claude/skills/stop-slop/references/phrases.md b/cmd/popola/.claude/skills/stop-slop/references/phrases.md new file mode 100644 index 0000000..dfff3c9 --- /dev/null +++ b/cmd/popola/.claude/skills/stop-slop/references/phrases.md @@ -0,0 +1,87 @@ +# Phrases to Remove + +## Throat-Clearing Openers + +Remove these announcement phrases. State the content directly. + +- "Here's the thing:" +- "The uncomfortable truth is" +- "It turns out" +- "The real [X] is" +- "Let me be clear" +- "The truth is," +- "I'll say it again:" +- "I'm going to be honest" +- "Can we talk about" +- "Here's what I find interesting" +- "Here's the problem though" + +## Emphasis Crutches + +These add no meaning. Delete them. + +- "Full stop." / "Period." +- "Let that sink in." +- "This matters because" +- "Make no mistake" +- "Here's why that matters" + +## Business Jargon + +Replace with plain language. + +| Avoid | Use instead | +| --------------------- | ---------------------- | +| Navigate (challenges) | Handle, address | +| Unpack (analysis) | Explain, examine | +| Lean into | Accept, embrace | +| Landscape (context) | Situation, field | +| Game-changer | Significant, important | +| Double down | Commit, increase | +| Deep dive | Analysis, examination | +| Take a step back | Reconsider | +| Moving forward | Next, from now | +| Circle back | Return to, revisit | +| On the same page | Aligned, agreed | + +## Filler Adverbs + +Cut or replace: + +- "At its core" +- "In today's [X]" +- "It's worth noting" +- "Interestingly," +- "Importantly," +- "Crucially," +- "At the end of the day" +- "When it comes to" +- "In a world where" +- "The reality is" + +## Meta-Commentary + +Remove self-referential asides: + +- "Hint:" +- "Plot twist:" / "Spoiler:" +- "You already know this, but" +- "But that's another post" +- "X is a feature, not a bug" +- "Dressed up as" + +## Performative Emphasis + +False intimacy or manufactured sincerity: + +- "creeps in" +- "I promise" +- "They exist, I promise" + +## Telling Instead of Showing + +Announcing difficulty or significance rather than demonstrating it: + +- "This is genuinely hard" +- "This is what leadership actually looks like" +- "This is what X actually looks like" diff --git a/cmd/popola/.claude/skills/stop-slop/references/structures.md b/cmd/popola/.claude/skills/stop-slop/references/structures.md new file mode 100644 index 0000000..de06152 --- /dev/null +++ b/cmd/popola/.claude/skills/stop-slop/references/structures.md @@ -0,0 +1,71 @@ +# Structures to Avoid + +## Binary Contrasts + +These create false drama. State the point directly. + +| Pattern | Problem | +| ----------------------------------- | ----------------------------- | +| "Not because X. Because Y." | Telegraphed reversal | +| "[X] isn't the problem. [Y] is." | Formulaic reframe | +| "The answer isn't X. It's Y." | Predictable pivot | +| "It feels like X. It's actually Y." | Setup/reveal cliche | +| "The question isn't X. It's Y." | Rhetorical misdirection | +| "Not X. But Y." | Mechanical contrast | +| "It's not this. It's that." | Same formula, different words | +| "stops being X and starts being Y" | False transformation arc | + +**Instead:** State Y directly. "The problem is Y." "Y matters here." + +## Dramatic Fragmentation + +Sentence fragments for emphasis read as manufactured profundity. + +| Pattern | Problem | +| ---------------------------------------- | ----------------------- | +| "[Noun]. That's it. That's the [thing]." | Performative simplicity | +| "X. And Y. And Z." | Staccato drama | +| "This unlocks something. [Word]." | Artificial revelation | + +**Instead:** Complete sentences. Trust content over presentation. + +## Rhetorical Setups + +These announce insight rather than deliver it. + +| Pattern | Problem | +| --------------------- | ---------------------- | +| "What if [reframe]?" | Socratic posturing | +| "Here's what I mean:" | Redundant preview | +| "Think about it:" | Condescending prompt | +| "And that's okay." | Unnecessary permission | + +**Instead:** Make the point. Let readers draw conclusions. + +## Formulaic Constructions + +| Pattern | Problem | +| ------------------------- | --------------------------- | +| "By the time X, I was Y." | Narrative template | +| "X that isn't Y" | Indirect. Say "X is broken" | + +## Rhythm Patterns + +| Pattern | Fix | +| ------------------------------- | ---------------------------------- | +| Three-item lists | Use two items or one | +| Questions answered immediately | Let questions breathe or cut them | +| Paragraphs starting with "So" | Start with content | +| Sentences starting with "Look," | Remove | +| Every paragraph ends punchily | Vary endings | +| Em-dashes before reveals | Use periods or commas | +| Staccato fragmentation | Don't stack short punchy sentences | +| Dashes for dramatic pause | Use commas or periods | +| "Not always. Not perfectly." | Hedging disguised as reassurance | + +## Word Patterns + +| Pattern | Problem | +| -------------------------------------------------------------------------------------------------- | --------------- | +| Absolute words (always, never, everyone, everybody, nobody) | False authority | +| AI-overused intensifiers (deeply, truly, fundamentally, inherently, simply, literally, inevitably) | Empty emphasis | diff --git a/cmd/popola/.claude/skills/writing-clearly-and-concisely/SKILL.md b/cmd/popola/.claude/skills/writing-clearly-and-concisely/SKILL.md new file mode 100644 index 0000000..ab7d661 --- /dev/null +++ b/cmd/popola/.claude/skills/writing-clearly-and-concisely/SKILL.md @@ -0,0 +1,66 @@ +--- +name: writing-clearly-and-concisely +description: Apply Strunk's timeless writing rules to ANY prose humans will read—documentation, commit messages, error messages, explanations, reports, or UI text. Makes your writing clearer, stronger, and more professional. +--- + +# Writing Clearly and Concisely + +## Overview + +William Strunk Jr.'s _The Elements of Style_ (1918) teaches you to write clearly and cut ruthlessly. + +**WARNING:** `elements-of-style.md` consumes ~12,000 tokens. Read it only when writing or editing prose. + +## When to Use This Skill + +Use this skill whenever you write prose for humans: + +- Documentation, README files, technical explanations +- Commit messages, pull request descriptions +- Error messages, UI copy, help text, comments +- Reports, summaries, or any explanation +- Editing to improve clarity + +**If you're writing sentences for a human to read, use this skill.** + +## Limited Context Strategy + +When context is tight: + +1. Write your draft using judgment +2. Dispatch a subagent with your draft and `elements-of-style.md` +3. Have the subagent copyedit and return the revision + +## All Rules + +### Elementary Rules of Usage (Grammar/Punctuation) + +1. Form possessive singular by adding 's +2. Use comma after each term in series except last +3. Enclose parenthetic expressions between commas +4. Comma before conjunction introducing co-ordinate clause +5. Don't join independent clauses by comma +6. Don't break sentences in two +7. Participial phrase at beginning refers to grammatical subject + +### Elementary Principles of Composition + +8. One paragraph per topic +9. Begin paragraph with topic sentence +10. **Use active voice** +11. **Put statements in positive form** +12. **Use definite, specific, concrete language** +13. **Omit needless words** +14. Avoid succession of loose sentences +15. Express co-ordinate ideas in similar form +16. **Keep related words together** +17. Keep to one tense in summaries +18. **Place emphatic words at end of sentence** + +### Section V: Words and Expressions Commonly Misused + +Alphabetical reference for usage questions + +## Bottom Line + +Writing for humans? Read `elements-of-style.md` and apply the rules. Low on tokens? Dispatch a subagent to copyedit with the guide. diff --git a/cmd/popola/.claude/skills/writing-clearly-and-concisely/elements-of-style.md b/cmd/popola/.claude/skills/writing-clearly-and-concisely/elements-of-style.md new file mode 100644 index 0000000..d9740de --- /dev/null +++ b/cmd/popola/.claude/skills/writing-clearly-and-concisely/elements-of-style.md @@ -0,0 +1,995 @@ +# The Elements of Style (1918) + +_Public domain text by William Strunk Jr._ + +## Contents + +- [I. Introductory](#i-introductory) +- [II. Elementary Rules Of Usage](#ii-elementary-rules-of-usage) + - [Rule 1. Form the possessive singular of nouns by adding 's.](#rule-1-form-the-possessive-singular-of-nouns-by-adding-s) + - [Rule 2. In a series of three or more terms with a single conjunction, use a comma after each term except the last.](#rule-2-in-a-series-of-three-or-more-terms-with-a-single-conjunction-use-a-comma-after-each-term-except-the-last) + - [Rule 3. Enclose parenthetic expressions between commas.](#rule-3-enclose-parenthetic-expressions-between-commas) + - [Rule 4. Place a comma before a conjunction introducing a co-ordinate clause.](#rule-4-place-a-comma-before-a-conjunction-introducing-a-co-ordinate-clause) + - [Rule 5. Do not join independent clauses by a comma.](#rule-5-do-not-join-independent-clauses-by-a-comma) + - [Rule 6. Do not break sentences in two.](#rule-6-do-not-break-sentences-in-two) + - [Rule 7. A participial phrase at the beginning of a sentence must refer to the grammatical subject.](#rule-7-a-participial-phrase-at-the-beginning-of-a-sentence-must-refer-to-the-grammatical-subject) +- [III. Elementary Principles Of Composition](#iii-elementary-principles-of-composition) + - [Rule 8. Make the paragraph the unit of composition: one paragraph to each topic.](#rule-8-make-the-paragraph-the-unit-of-composition-one-paragraph-to-each-topic) + - [Rule 9. As a rule, begin each paragraph with a topic sentence, end it in conformity with the beginning.](#rule-9-as-a-rule-begin-each-paragraph-with-a-topic-sentence-end-it-in-conformity-with-the-beginning) + - [Rule 10. Use the active voice.](#rule-10-use-the-active-voice) + - [Rule 11. Put statements in positive form.](#rule-11-put-statements-in-positive-form) + - [Rule 12. Use definite, specific, concrete language.](#rule-12-use-definite-specific-concrete-language) + - [Rule 13. Omit needless words.](#rule-13-omit-needless-words) + - [Rule 14. Avoid a succession of loose sentences](#rule-14-avoid-a-succession-of-loose-sentences) + - [Rule 15. Express co-ordinate ideas in similar form.](#rule-15-express-co-ordinate-ideas-in-similar-form) + - [Rule 16. Keep related words together.](#rule-16-keep-related-words-together) + - [Rule 17. In summaries, keep to one tense.](#rule-17-in-summaries-keep-to-one-tense) + - [Rule 18. Place the emphatic words of a sentence at the end.](#rule-18-place-the-emphatic-words-of-a-sentence-at-the-end) +- [V. Words And Expressions Commonly Misused](#v-words-and-expressions-commonly-misused) + +## I. Introductory + +This handbook summarizes the essentials of plain English style. It focuses on the rules of usage and principles of composition most often broken, offering a compact alternative to exhaustive manuals. Master the guidance here, then look to the best authors for finer points of style. + +## II. Elementary Rules Of Usage + +### Rule 1. Form the possessive singular of nouns by adding 's. + +Follow this rule whatever the final consonant. Thus write, + +Charles's friend + +Burns's poems + +the witch's malice + +This is the usage of the United States Government Printing Office and of the Oxford University Press. + +Exceptions are the possessive of ancient proper names in _-es_ and _-is_, the possessive _Jesus'_, and such forms as _for conscience' sake_, _for righteousness' sake_. But such forms as _Achilles' heel_, _Moses' laws_, _Isis' temple_ are commonly replaced by + +the heel of Achilles + +the laws of Moses + +the temple of Isis + +The pronominal possessives _hers_, _its_, _theirs_, _yours_, and _oneself_ have no apostrophe. + +### Rule 2. In a series of three or more terms with a single conjunction, use a comma after each term except the last. + +Thus write, + +red, white, and blue + +gold, silver, or copper + +He opened the letter, read it, and made a note of its contents. + +This is also the usage of the Government Printing Office and of the Oxford University Press. + +In the names of business firms the last comma is omitted, as, + +Brown, Shipley & Co. + +### Rule 3. Enclose parenthetic expressions between commas. + +The best way to see a country, unless you are pressed for time, is to travel on foot. + +This rule is difficult to apply; it is frequently hard to decide whether a single word, such as _however_, or a brief phrase, is or is not parenthetic. If the interruption to the flow of the sentence is but slight, the writer may safely omit the commas. But whether the interruption be slight or considerable, he must never insert one comma and omit the other. Such punctuation as + +Marjorie's husband, Colonel Nelson paid us a visit yesterday, + +or + +My brother you will be pleased to hear, is now in perfect health, + +is indefensible. + +If a parenthetic expression is preceded by a conjunction, place the first comma before the conjunction, not after it. + +He saw us coming, and unaware that we had learned of his treachery, greeted us with a smile. + +Always to be regarded as parenthetic and to be enclosed between commas (or, at the end of the sentence, between comma and period) are the following: + +\(1\) the year, when forming part of a date, and the day of the month, when following the day of the week: + +February to July, 1916. + +April 6, 1917. + +Monday, November 11, 1918. + +\(2\) the abbreviations _etc._ and _jr._ + +\(3\) non-restrictive relative clauses, that is, those which do not serve to identify or define the antecedent noun, and similar clauses introduced by conjunctions indicating time or place. + +The audience, which had at first been indifferent, became more and more interested. + +In this sentence the clause introduced by _which_ does not serve to tell which of several possible audiences is meant; what audience is in question is supposed to be already known. The clause adds, parenthetically, a statement supplementing that in the main clause. The sentence is virtually a combination of two statements which might have been made independently: + +The audience had at first been indifferent. It became more and more interested. + +Compare the restrictive relative clause, not set off by commas, in the sentence, + +The candidate who best meets these requirements will obtain the place. + +Here the clause introduced by _who_ does serve to tell which of several possible candidates is meant; the sentence cannot be split up into two independent statements. + +The difference in punctuation in the two sentences following is based on the same principle: + +Nether Stowey, where Coleridge wrote The Rime of the Ancient Mariner, is a few miles from Bridgewater. + +The day will come when you will admit your mistake. + +Nether Stowey is completely identified by its name; the statement about Coleridge is therefore supplementary and parenthetic. The _day_ spoken of is identified only by the dependent clause, which is therefore restrictive. + +Similar in principle to the enclosing of parenthetic expressions between commas is the setting off by commas of phrases or dependent clauses preceding or following the main clause of a sentence. + +Partly by hard fighting, partly by diplomatic skill, they enlarged their dominions to the east, and rose to royal rank with the possession of Sicily, exchanged afterwards for Sardinia. + +Other illustrations may be found in sentences quoted under Rules 4, 5, 6, 7, 16, and 18. + +The writer should be careful not to set off independent clauses by commas: see under Rule 5. + +### Rule 4. Place a comma before a conjunction introducing a co-ordinate clause. + +The early records of the city have disappeared, and the story of its first years can no longer be reconstructed. + +The situation is perilous, but there is still one chance of escape. + +Sentences of this type, isolated from their context, may seem to be in need of rewriting. As they make complete sense when the comma is reached, the second clause has the appearance of an afterthought. Further, _and_ is the least specific of connectives. Used between independent clauses, it indicates only that a relation exists between them without defining that relation. In the example above, the relation is that of cause and result. The two sentences might be rewritten: + +As the early records of the city have disappeared, the story of its first years can no longer be reconstructed. + +Although the situation is perilous, there is still one chance of escape. + +Or the subordinate clauses might be replaced by phrases: + +Owing to the disappearance of the early records of the city, the story of its first years can no longer be reconstructed. + +In this perilous situation, there is still one chance of escape. + +But a writer may err by making his sentences too uniformly compact and periodic, and an occasional loose sentence prevents the style from becoming too formal and gives the reader a certain relief. Consequently, loose sentences of the type first quoted are common in easy, unstudied writing. But a writer should be careful not to construct too many of his sentences after this pattern (see Rule 14). + +Two-part sentences of which the second member is introduced by _as_ (in the sense of _because_), _for_, _or_, _nor_, and _while_ (in the sense of _and at the same time_) likewise require a comma before the conjunction. + +If the second member is introduced by an adverb, a semicolon, not a comma, is required (see Rule 5). The connectives _so_ and _yet_ may be used either as adverbs or as conjunctions, accordingly as the second clause is felt to be co-ordinate or subordinate; consequently either mark of punctuation may be justified. But these uses of _so_ (equivalent to _accordingly_ or to _so that_) are somewhat colloquial and should, as a rule, be avoided in writing. A simple correction, usually serviceable, is to omit the word _so_ and begin the first clause with _as_ or _since_: + +| Original | Revision | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| I had never been in the place before; so I had difficulty in finding my way about. | As I had never been in the place before, I had difficulty in finding my way about. | + +If a dependent clause, or an introductory phrase requiring to be set off by a comma, precedes the second independent clause, no comma is needed after the conjunction. + +The situation is perilous, but if we are prepared to act promptly, there is still one chance of escape. + +When the subject is the same for both clauses and is expressed only once, a comma is required if the connective is _but_. If the connective is _and_, the comma should be omitted if the relation between the two statements is close or immediate. + +I have heard his arguments, but am still unconvinced. + +He has had several years' experience and is thoroughly competent. + +### Rule 5. Do not join independent clauses by a comma. + +If two or more clauses, grammatically complete and not joined by a conjunction, are to form a single compound sentence, the proper mark of punctuation is a semicolon. + +Stevenson's romances are entertaining; they are full of exciting adventures. + +It is nearly half past five; we cannot reach town before dark. + +It is of course equally correct to write the above as two sentences each, replacing the semicolons by periods. + +Stevenson's romances are entertaining. They are full of exciting adventures. + +It is nearly half past five. We cannot reach town before dark. + +If a conjunction is inserted the proper mark is a comma (Rule 4). + +Stevenson's romances are entertaining, for they are full of exciting adventures. + +It is nearly half past five, and we cannot reach town before dark. + +A comparison of the three forms given above will show clearly the advantage of the first. It is, at least in the examples given, better than the second form, because it suggests the close relationship between the two statements in a way that the second does not attempt, and better than the third, because briefer and therefore more forcible. Indeed it may be said that this simple method of indicating relationship between statements is one of the most useful devices of composition. The relationship, as above, is commonly one of cause or of consequence. + +Note that if the second clause is preceded by an adverb, such as _accordingly_, _besides_, _then_, _therefore_, or _thus_, and not by a conjunction, the semicolon is still required. + +Two exceptions to the rule may be admitted. If the clauses are very short, and are alike in form, a comma is usually permissible: + +Man proposes, God disposes. + +The gate swung apart, the bridge fell, the portcullis was drawn up. + +Note that in these examples the relation is not one of cause or consequence. Also in the colloquial form of expression, + +I hardly knew him, he was so changed, + +a comma, not a semicolon, is required. But this form of expression is inappropriate in writing, except in the dialogue of a story or play, or perhaps in a familiar letter. + +### Rule 6. Do not break sentences in two. + +In other words, do not use periods for commas. + +I met them on a Cunard liner several years ago. Coming home from Liverpool to New York. + +He was an interesting talker. A man who had traveled all over the world and lived in half a dozen countries. + +In both these examples, the first period should be replaced by a comma, and the following word begun with a small letter. + +It is permissible to make an emphatic word or expression serve the purpose of a sentence and to punctuate it accordingly: + +Again and again he called out. No reply. + +The writer must, however, be certain that the emphasis is warranted, and that he will not be suspected of a mere blunder in syntax or in punctuation. + +Rules 3, 4, 5, and 6 cover the most important principles in the punctuation of ordinary sentences; they should be so thoroughly mastered that their application becomes second nature. + +### Rule 7. A participial phrase at the beginning of a sentence must refer to the grammatical subject. + +Walking slowly down the road, he saw a woman accompanied by two children. + +The word _walking_ refers to the subject of the sentence, not to the woman. If the writer wishes to make it refer to the woman, he must recast the sentence: + +He saw a woman accompanied by two children, walking slowly down the road. + +Participial phrases preceded by a conjunction or by a preposition, nouns in apposition, adjectives, and adjective phrases come under the same rule if they begin the sentence. + +| Original | Revision | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| On arriving in Chicago, his friends met him at the station. | When he arrived (or, On his arrival) in Chicago, his friends met him at the station. | +| A soldier of proved valor, they entrusted him with the defence of the city. | A soldier of proved valor, he was entrusted with the defence of the city. | +| Young and inexperienced, the task seemed easy to me. | Young and inexperienced, I thought the task easy. | +| Without a friend to counsel him, the temptation proved irresistible. | Without a friend to counsel him, he found the temptation irresistible. | + +Sentences violating this rule are often ludicrous. + +Being in a dilapidated condition, I was able to buy the house very cheap. + +Wondering irresolutely what to do next, the clock struck twelve. + +## III. Elementary Principles Of Composition + +### Rule 8. Make the paragraph the unit of composition: one paragraph to each topic. + +If the subject on which you are writing is of slight extent, or if you intend to treat it very briefly, there may be no need of subdividing it into topics. Thus a brief description, a brief summary of a literary work, a brief account of a single incident, a narrative merely outlining an action, the setting forth of a single idea, any one of these is best written in a single paragraph. After the paragraph has been written, examine it to see whether subdivision will not improve it. + +Ordinarily, however, a subject requires subdivision into topics, each of which should be made the subject of a paragraph. The object of treating each topic in a paragraph by itself is, of course, to aid the reader. The beginning of each paragraph is a signal to him that a new step in the development of the subject has been reached. + +The extent of subdivision will vary with the length of the composition. For example, a short notice of a book or poem might consist of a single paragraph. One slightly longer might consist of two paragraphs: + +- A. Account of the work. +- B. Critical discussion. + +A report on a poem, written for a class in literature, might consist of seven paragraphs: + +- A. Facts of composition and publication. +- B. Kind of poem; metrical form. +- C. Subject. +- D. Treatment of subject. +- E. For what chiefly remarkable. +- F. Wherein characteristic of the writer. +- G. Relationship to other works. + +The contents of paragraphs C and D would vary with the poem. Usually, paragraph C would indicate the actual or imagined circumstances of the poem (the situation), if these call for explanation, and would then state the subject and outline its development. If the poem is a narrative in the third person throughout, paragraph C need contain no more than a concise summary of the action. Paragraph D would indicate the leading ideas and show how they are made prominent, or would indicate what points in the narrative are chiefly emphasized. + +A novel might be discussed under the heads: + +- A. Setting. +- B. Plot. +- C. Characters. +- D. Purpose. + +An historical event might be discussed under the heads: + +- A. What led up to the event. +- B. Account of the event. +- C. What the event led up to. + +In treating either of these last two subjects, the writer would probably find it necessary to subdivide one or more of the topics here given. + +As a rule, single sentences should not be written or printed as paragraphs. An exception may be made of sentences of transition, indicating the relation between the parts of an exposition or argument. Frequent exceptions are also necessary in textbooks, guidebooks, and other works in which many topics are treated briefly. + +In dialogue, each speech, even if only a single word, is a paragraph by itself; that is, a new paragraph begins with each change of speaker. The application of this rule, when dialogue and narrative are combined, is best learned from examples in well-printed works of fiction. + +### Rule 9. As a rule, begin each paragraph with a topic sentence, end it in conformity with the beginning. + +Again, the object is to aid the reader. The practice here recommended enables him to discover the purpose of each paragraph as he begins to read it, and to retain this purpose in mind as he ends it. For this reason, the most generally useful kind of paragraph, particularly in exposition and argument, is that in which + +\(a\) the topic sentence comes at or near the beginning; + +\(b\) the succeeding sentences explain or establish or develop the statement made in the topic sentence; and + +\(c\) the final sentence either emphasizes the thought of the topic sentence or states some important consequence. + +Ending with a digression, or with an unimportant detail, is particularly to be avoided. + +If the paragraph forms part of a larger composition, its relation to what precedes, or its function as a part of the whole, may need to be expressed. This can sometimes be done by a mere word or phrase (_again_; _therefore_; _for the same reason_) in the topic sentence. Sometimes, however, it is expedient to precede the topic sentence by one or more sentences of introduction or transition. If more than one such sentence is required, it is generally better to set apart the transitional sentences as a separate paragraph. + +According to the writer's purpose, he may, as indicated above, relate the body of the paragraph to the topic sentence in one or more of several different ways. He may make the meaning of the topic sentence clearer by restating it in other forms, by defining its terms, by denying the contrary, by giving illustrations or specific instances; he may establish it by proofs; or he may develop it by showing its implications and consequences. In a long paragraph, he may carry out several of these processes. + +1 Now, to be properly enjoyed, a walking tour should be gone upon alone. 2 If you go in a company, or even in pairs, it is no longer a walking tour in anything but name; it is something else and more in the nature of a picnic. 3 A walking tour should be gone upon alone, because freedom is of the essence; because you should be able to stop and go on, and follow this way or that, as the freak takes you; and because you must have your own pace, and neither trot alongside a champion walker, nor mince in time with a girl. 4 And you must be open to all impressions and let your thoughts take colour from what you see. 5 You should be as a pipe for any wind to play upon. 6 “I cannot see the wit,” says Hazlitt, “of walking and talking at the same time. 7 When I am in the country, I wish to vegetate like the country,” which is the gist of all that can be said upon the matter. 8 There should be no cackle of voices at your elbow, to jar on the meditative silence of the morning. 9 And so long as a man is reasoning he cannot surrender himself to that fine intoxication that comes of much motion in the open air, that begins in a sort of dazzle and sluggishness of the brain, and ends in a peace that passes comprehension.—Stevenson, Walking Tours. + +1 Topic sentence. 2 The meaning made clearer by denial of the contrary. 3 The topic sentence repeated, in abridged form, and supported by three reasons; the meaning of the third (“you must have your own pace”) made clearer by denying the contrary. 4 A fourth reason, stated in two forms. 5 The same reason, stated in still another form. 6–7 The same reason as stated by Hazlitt. 8 Repetition, in paraphrase, of the quotation from Hazlitt. 9 Final statement of the fourth reason, in language amplified and heightened to form a strong conclusion. + +1 It was chiefly in the eighteenth century that a very different conception of history grew up. 2 Historians then came to believe that their task was not so much to paint a picture as to solve a problem; to explain or illustrate the successive phases of national growth, prosperity, and adversity. 3 The history of morals, of industry, of intellect, and of art; the changes that take place in manners or beliefs; the dominant ideas that prevailed in successive periods; the rise, fall, and modification of political constitutions; in a word, all the conditions of national well-being became the subject of their works. 4 They sought rather to write a history of peoples than a history of kings. 5 They looked especially in history for the chain of causes and effects. 6 They undertook to study in the past the physiology of nations, and hoped by applying the experimental method on a large scale to deduce some lessons of real value about the conditions on which the welfare of society mainly depend.—Lecky, The Political Value of History. + +1 Topic sentence. 2 The meaning of the topic sentence made clearer; the new conception of history defined. 3 The definition expanded. 4 The definition explained by contrast. 5 The definition supplemented: another element in the new conception of history. 6 Conclusion: an important consequence of the new conception of history. + +In narration and description the paragraph sometimes begins with a concise, comprehensive statement serving to hold together the details that follow. + +The breeze served us admirably. + +The campaign opened with a series of reverses. + +The next ten or twelve pages were filled with a curious set of entries. + +But this device, if too often used, would become a mannerism. More commonly the opening sentence simply indicates by its subject with what the paragraph is to be principally concerned. + +At length I thought I might return towards the stockade. + +He picked up the heavy lamp from the table and began to explore. + +Another flight of steps, and they emerged on the roof. + +The brief paragraphs of animated narrative, however, are often without even this semblance of a topic sentence. The break between them serves the purpose of a rhetorical pause, throwing into prominence some detail of the action. + +### Rule 10. Use the active voice. + +The active voice is usually more direct and vigorous than the passive: + +I shall always remember my first visit to Boston. + +This is much better than + +My first visit to Boston will always be remembered by me. + +The latter sentence is less direct, less bold, and less concise. If the writer tries to make it more concise by omitting “by me,” + +My first visit to Boston will always be remembered, + +it becomes indefinite: is it the writer, or some person undisclosed, or the world at large, that will always remember this visit? + +This rule does not, of course, mean that the writer should entirely discard the passive voice, which is frequently convenient and sometimes necessary. + +The dramatists of the Restoration are little esteemed to-day. + +Modern readers have little esteem for the dramatists of the Restoration. + +The first would be the right form in a paragraph on the dramatists of the Restoration; the second, in a paragraph on the tastes of modern readers. The need of making a particular word the subject of the sentence will often, as in these examples, determine which voice is to be used. + +As a rule, avoid making one passive depend directly upon another. + +| Original | Revision | +| ----------------------------------------------------------- | -------------------------------------------------------------------- | +| Gold was not allowed to be exported. | It was forbidden to export gold (The export of gold was prohibited). | +| He has been proved to have been seen entering the building. | It has been proved that he was seen to enter the building. | + +In both the examples above, before correction, the word properly related to the second passive is made the subject of the first. + +A common fault is to use as the subject of a passive construction a noun which expresses the entire action, leaving to the verb no function beyond that of completing the sentence. + +| Original | Revision | +| ------------------------------------------------- | ---------------------------------- | +| A survey of this region was made in 1900. | This region was surveyed in 1900. | +| Mobilization of the army was rapidly effected. | The army was rapidly mobilized. | +| Confirmation of these reports cannot be obtained. | These reports cannot be confirmed. | + +Compare the _sentence,_ “The export of gold was prohibited,” in which the predicate “was prohibited” expresses something not implied in “export.” + +The habitual use of the active voice makes for forcible writing. This is true not only in narrative principally concerned with action, but in writing of any kind. Many a tame sentence of description or exposition can be made lively and emphatic by substituting a verb in the active voice for some such perfunctory expression as _there is_, or _could be heard_. + +| Original | Revision | +| ---------------------------------------------------------------------- | ------------------------------------------------ | +| There were a great number of dead leaves lying on the ground. | Dead leaves covered the ground. | +| The sound of a guitar somewhere in the house could be heard. | Somewhere in the house a guitar hummed sleepily. | +| The reason that he left college was that his health became impaired. | Failing health compelled him to leave college. | +| It was not long before he was very sorry that he had said what he had. | He soon repented his words. | + +### Rule 11. Put statements in positive form. + +Make definite assertions. Avoid tame, colorless, hesitating, non-committal language. Use the word _not_ as a means of denial or in antithesis, never as a means of evasion. + +| Original | Revision | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| He was not very often on time. | He usually came late. | +| He did not think that studying Latin was much use. | He thought the study of Latin useless. | +| The Taming of the Shrew is rather weak in spots. Shakespeare does not portray Katharine as a very admirable character, nor does Bianca remain long in memory as an important character in Shakespeare's works. | The women in The Taming of the Shrew are unattractive. Katharine is disagreeable, Bianca insignificant. | + +The last example, before correction, is indefinite as well as negative. The corrected version, consequently, is simply a guess at the writer's intention. + +All three examples show the weakness inherent in the word _not_. Consciously or unconsciously, the reader is dissatisfied with being told only what is not; he wishes to be told what is. Hence, as a rule, it is better to express even a negative in positive form. + +| Original | Revision | +| ------------------------------- | ---------- | +| not honest | dishonest | +| not important | trifling | +| did not remember | forgot | +| did not pay any attention to | ignored | +| did not have much confidence in | distrusted | + +The antithesis of negative and positive is strong: + +Not charity, but simple justice. + +Not that I loved Caesar less, but Rome the more. + +Negative words other than _not_ are usually strong: + +The sun never sets upon the British flag. + +### Rule 12. Use definite, specific, concrete language. + +Prefer the specific to the general, the definite to the vague, the concrete to the abstract. + +| Original | Revision | +| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| A period of unfavorable weather set in. | It rained every day for a week. | +| He showed satisfaction as he took possession of his well-earned reward. | He grinned as he pocketed the coin. | +| There is a general agreement among those who have enjoyed the experience that surf-riding is productive of great exhilaration. | All who have tried surf-riding agree that it is most exhilarating. | + +If those who have studied the art of writing are in accord on any one point, it is on this, that the surest method of arousing and holding the attention of the reader is by being specific, definite, and concrete. Critics have pointed out how much of the effectiveness of the greatest writers, Homer, Dante, Shakespeare, results from their constant definiteness and concreteness. Browning, to cite a more modern author, affords many striking examples. Take, for instance, the lines from My Last Duchess, + +Sir, 'twas all one! My favour at her breast, + +The dropping of the daylight in the west, + +The bough of cherries some officious fool + +Broke in the orchard for her, the white mule + +She rode with round the terrace—all and each + +Would draw from her alike the approving speech, + +Or blush, at least, + +and those which end the poem, + +Notice Neptune, though, + +Taming a sea-horse, thought a rarity, + +Which Claus of Innsbruck cast in bronze for me. + +These words call up pictures. Recall how in The Bishop Orders his Tomb in St. Praxed's Church “the Renaissance spirit—its worldliness, inconsistency, pride, hypocrisy, ignorance of itself, love of art, of luxury, of good Latin,” to quote Ruskin's comment on the poem, is made manifest in specific details and in concrete terms. + +Prose, in particular narrative and descriptive prose, is made vivid by the same means. If the experiences of Jim Hawkins and of David Balfour, of Kim, of Nostromo, have seemed for the moment real to countless readers, if in reading Carlyle we have almost the sense of being physically present at the taking of the Bastille, it is because of the definiteness of the details and the concreteness of the terms used. It is not that every detail is given; that would be impossible, as well as to no purpose; but that all the significant details are given, and not vaguely, but with such definiteness that the reader, in imagination, can project himself into the scene. + +In exposition and in argument, the writer must likewise never lose his hold upon the concrete, and even when he is dealing with general principles, he must give particular instances of their application. + +“This superiority of specific expressions is clearly due to the effort required to translate words into thoughts. As we do not think in generals, but in particulars—as whenever any class of things is referred to, we represent it to ourselves by calling to mind individual members of it, it follows that when an abstract word is used, the hearer or reader has to choose, from his stock of images, one or more by which he may figure to himself the genus mentioned. In doing this, some delay must arise, some force be expended; and if by employing a specific term an appropriate image can be at once suggested, an economy is achieved, and a more vivid impression produced.” + +Herbert Spencer, from whose Philosophy of Style the preceding paragraph is quoted, illustrates the principle by the sentences: + +| Original | Revision | +| ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| In proportion as the manners, customs, and amusements of a nation are cruel and barbarous, the regulations of their penal code will be severe. | In proportion as men delight in battles, bull-fights, and combats of gladiators, will they punish by hanging, burning, and the rack. | + +### Rule 13. Omit needless words. + +Vigorous writing is concise. A sentence should contain no unnecessary words, a paragraph no unnecessary sentences, for the same reason that a drawing should have no unnecessary lines and a machine no unnecessary parts. This requires not that the writer make all his sentences short, or that he avoid all detail and treat his subjects only in outline, but that he make every word tell. + +Many expressions in common use violate this principle: + +| Original | Revision | +| --------------------------- | ------------------------------ | +| the question as to whether | whether (the question whether) | +| there is no doubt but that | no doubt (doubtless) | +| used for fuel purposes | used for fuel | +| he is a man who | he | +| in a hasty manner | hastily | +| this is a subject which | this subject | +| His story is a strange one. | His story is strange. | + +In especial the expression _the fact that_ should be revised out of every sentence in which it occurs. + +| Original | Revision | +| ------------------------------------ | --------------------------------- | +| owing to the fact that | since (because) | +| in spite of the fact that | though (although) | +| call your attention to the fact that | remind you (notify you) | +| I was unaware of the fact that | I was unaware that (did not know) | +| the fact that he had not succeeded | his failure | +| the fact that I had arrived | my arrival | + +See also under _case_, _character_, _nature_, _system_ in Chapter V. + +_Who is_, _which was_, and the like are often superfluous. + +| Original | Revision | +| --------------------------------------------- | -------------------------------------- | +| His brother, who is a member of the same firm | His brother, a member of the same firm | +| Trafalgar, which was Nelson's last battle | Trafalgar, Nelson's last battle | + +As positive statement is more concise than negative, and the active voice more concise than the passive, many of the examples given under Rules 11 and 12 illustrate this rule as well. + +A common violation of conciseness is the presentation of a single complex idea, step by step, in a series of sentences or independent clauses which might to advantage be combined into one. + +| Original | Revision | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Macbeth was very ambitious. This led him to wish to become king of Scotland. The witches told him that this wish of his would come true. The king of Scotland at this time was Duncan. Encouraged by his wife, Macbeth murdered Duncan. He was thus enabled to succeed Duncan as king. (51 words.) | Encouraged by his wife, Macbeth achieved his ambition and realized the prediction of the witches by murdering Duncan and becoming king of Scotland in his place. (26 words.) | +| There were several less important courses, but these were the most important, and although they did not come every day, they came often enough to keep you in such a state of mind that you never knew what your next move would be. (43 words.) | These, the most important courses of all, came, if not daily, at least often enough to keep one under constant strain. (21 words.) | + +### Rule 14. Avoid a succession of loose sentences + +This rule refers especially to loose sentences of a particular type, those consisting of two co-ordinate clauses, the second introduced by a conjunction or relative. Although single sentences of this type may be unexceptionable (see under Rule 4), a series soon becomes monotonous and tedious. + +An unskilful writer will sometimes construct a whole paragraph of sentences of this kind, using as connectives _and_, _but_, _so_, and less frequently, _who_, _which_, _when_, _where_, and _while_, these last in non-restrictive senses (see under Rule 3). + +The third concert of the subscription series was given last evening, and a large audience was in attendance. Mr. Edward Appleton was the soloist, and the Boston Symphony Orchestra furnished the instrumental music. The former showed himself to be an artist of the first rank, while the latter proved itself fully deserving of its high reputation. The interest aroused by the series has been very gratifying to the Committee, and it is planned to give a similar series annually hereafter. The fourth concert will be given on Tuesday, May 10, when an equally attractive programme will be presented. + +Apart from its triteness and emptiness, the paragraph above is weak because of the structure of its sentences, with their mechanical symmetry and sing-song. Contrast with them the sentences in the paragraphs quoted under Rule 9, or in any piece of good English prose, as the preface (Before the Curtain) to Vanity Fair. + +If the writer finds that he has written a series of sentences of the type described, he should recast enough of them to remove the monotony, replacing them by simple sentences, by sentences of two clauses joined by a semicolon, by periodic sentences of two clauses, by sentences, loose or periodic, of three clauses—whichever best represent the real relations of the thought. + +### Rule 15. Express co-ordinate ideas in similar form. + +This principle, that of parallel construction, requires that expressions of similar content and function should be outwardly similar. The likeness of form enables the reader to recognize more readily the likeness of content and function. Familiar instances from the Bible are the Ten Commandments, the Beatitudes, and the petitions of the Lord's Prayer. + +The unskillful writer often violates this principle, from a mistaken belief that he should constantly vary the form of his expressions. It is true that in repeating a statement in order to emphasize it he may have need to vary its form. For illustration, see the paragraph from Stevenson quoted under Rule _9_. But apart from this, he should follow the principle of parallel construction. + +| Original | Revision | +| ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Formerly, science was taught by the textbook method, while now the laboratory method is employed. | Formerly, science was taught by the textbook method; now it is taught by the laboratory method. | + +The left-hand version gives the impression that the writer is undecided or timid; he seems unable or afraid to choose one form of expression and hold to it. The right-hand version shows that the writer has at least made his choice and abided by it. + +By this principle, an article or a preposition applying to all the members of a series must either be used only before the first term or else be repeated before each term. + +| Original | Revision | +| ------------------------------------------------- | ----------------------------------------------------------------- | +| The French, the Italians, Spanish, and Portuguese | The French, the Italians, the Spanish, and the Portuguese | +| In spring, summer, or in winter | In spring, summer, or winter (In spring, in summer, or in winter) | + +Correlative expressions (_both, and_; _not, but_; _not only, but also_; _either, or_; _first, second, third_; and the like) should be followed by the same grammatical construction, that is, virtually, by the same part of speech. (Such combinations as “both Henry and I,” “not silk, but a cheap substitute,” are obviously within the rule.) Many violations of this rule (as the first three below) arise from faulty arrangement; others (as the last) from the use of unlike constructions. + +| Original | Revision | +| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| It was both a long ceremony and very tedious. | The ceremony was both long and tedious. | +| A time not for words, but action. | A time not for words, but for action. | +| Either you must grant his request or incur his ill will. | You must either grant his request or incur his ill will. | +| My objections are, first, the injustice of the measure; second, that it is unconstitutional. | My objections are, first, that the measure is unjust; second, that it is unconstitutional. | + +See also the third example under Rule 12 and the last under Rule 13. + +It may be asked, what if a writer needs to express a very large number of similar ideas, say twenty? Must he write twenty consecutive sentences of the same pattern? On closer examination he will probably find that the difficulty is imaginary, that his twenty ideas can be classified in groups, and that he need apply the principle only within each group. Otherwise he had best avoid difficulty by putting his statements in the form of a table. + +### Rule 16. Keep related words together. + +The position of the words in a sentence is the principal means of showing their relationship. The writer must therefore, so far as possible, bring together the words, and groups of words, that are related in thought, and keep apart those which are not so related. + +The subject of a sentence and the principal verb should not, as a rule, be separated by a phrase or clause that can be transferred to the beginning. + +| Original | Revision | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| Wordsworth, in the fifth book of The Excursion, gives a minute description of this church. | In the fifth book of The Excursion, Wordsworth gives a minute description of this church. | +| Cast iron, when treated in a Bessemer converter, is changed into steel. | By treatment in a Bessemer converter, cast iron is changed into steel. | + +The objection is that the interposed phrase or clause needlessly interrupts the natural order of the main clause. Usually, however, this objection does not hold when the order is interrupted only by a relative clause or by an expression in apposition. Nor does it hold in periodic sentences in which the interruption is a deliberately used means of creating suspense (see examples under Rule 18). + +The relative pronoun should come, as a rule, immediately after its antecedent. + +| Original | Revision | +| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| There was a look in his eye that boded mischief. | In his eye was a look that boded mischief. | +| He wrote three articles about his adventures in Spain, which were published in Harper's Magazine. | He published in Harper's Magazine three articles about his adventures in Spain. | +| This is a portrait of Benjamin Harrison, grandson of William Henry Harrison, who became President in 1889. | This is a portrait of Benjamin Harrison, grandson of William Henry Harrison. He became President in 1889. | + +If the antecedent consists of a group of words, the relative comes at the end of the group, unless this would cause ambiguity. + +The Superintendent of the Chicago Division, who + +| Original | Revision | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| A proposal to amend the Sherman Act, which has been variously judged. | A proposal, which has been variously judged, to amend the Sherman Act. | +| — | A proposal to amend the much-debated Sherman Act. | +| The grandson of William Henry Harrison, who | William Henry Harrison's grandson, who | + +A noun in apposition may come between antecedent and relative, because in such a combination no real ambiguity can arise. + +The Duke of York, his brother, who was regarded with hostility by the Whigs + +Modifiers should come, if possible, next to the word they modify. If several expressions modify the same word, they should be so arranged that no wrong relation is suggested. + +| Original | Revision | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| All the members were not present. | Not all the members were present. | +| He only found two mistakes. | He found only two mistakes. | +| Major R. E. Joyce will give a lecture on Tuesday evening in Bailey Hall, to which the public is invited, on “My Experiences in Mesopotamia” at eight P. M. | On Tuesday evening at eight P. M., Major R. E. Joyce will give in Bailey Hall a lecture on “My Experiences in Mesopotamia.” The public is invited. | + +### Rule 17. In summaries, keep to one tense. + +In summarizing the action of a drama, the writer should always use the present tense. In summarizing a poem, story, or novel, he should preferably use the present, though he may use the past if he prefers. If the summary is in the present tense, antecedent action should be expressed by the perfect; if in the past, by the past perfect. + +An unforeseen chance prevents Friar John from delivering Friar Lawrence's letter to Romeo. Meanwhile, owing to her father's arbitrary change of the day set for her wedding, Juliet has been compelled to drink the potion on Tuesday night, with the result that Balthasar informs Romeo of her supposed death before Friar Lawrence learns of the non-delivery of the letter. + +But whichever tense be used in the summary, a past tense in indirect discourse or in indirect question remains unchanged. + +The Friar confesses that it was he who married them. + +Apart from the exceptions noted, whichever tense the writer chooses, he should use throughout. Shifting from one tense to the other gives the appearance of uncertainty and irresolution (compare Rule 15). + +In presenting the statements or the thought of some one else, as in summarizing an essay or reporting a speech, the writer should avoid intercalating such expressions as “he said,” “he stated,” “the speaker added,” “the speaker then went on to say,” “the author also thinks,” or the like. He should indicate clearly at the outset, once for all, that what follows is summary, and then waste no words in repeating the notification. + +In notebooks, in newspapers, in handbooks of literature, summaries of one kind or another may be indispensable, and for children in primary schools it is a useful exercise to retell a story in their own words. But in the criticism or interpretation of literature the writer should be careful to avoid dropping into summary. He may find it necessary to devote one or two sentences to indicating the subject, or the opening situation, of the work he is discussing; he may cite numerous details to illustrate its qualities. But he should aim to write an orderly discussion supported by evidence, not a summary with occasional comment. Similarly, if the scope of his discussion includes a number of works, he will as a rule do better not to take them up singly in chronological order, but to aim from the beginning at establishing general conclusions. + +### Rule 18. Place the emphatic words of a sentence at the end. + +The proper place in the sentence for the word, or group of words, which the writer desires to make most prominent is usually the end. + +| Original | Revision | +| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Humanity has hardly advanced in fortitude since that time, though it has advanced in many other ways. | Humanity, since that time, has advanced in many other ways, but it has hardly advanced in fortitude. | +| This steel is principally used for making razors, because of its hardness. | Because of its hardness, this steel is principally used in making razors. | + +The word or group of words entitled to this position of prominence is usually the logical predicate, that is, the _new_ element in the sentence, as it is in the second example. + +The effectiveness of the periodic sentence arises from the prominence which it gives to the main statement. + +Four centuries ago, Christopher Columbus, one of the Italian mariners whom the decline of their own republics had put at the service of the world and of adventure, seeking for Spain a westward passage to the Indies as a set-off against the achievements of Portuguese discoverers, lighted on America. + +With these hopes and in this belief I would urge you, laying aside all hindrance, thrusting away all private aims, to devote yourself unswervingly and unflinchingly to the vigorous and successful prosecution of this war. + +The other prominent position in the sentence is the beginning. Any element in the sentence, other than the subject, may become emphatic when placed first. + +Deceit or treachery he could never forgive. + +So vast and rude, fretted by the action of nearly three thousand years, the fragments of this architecture may often seem, at first sight, like works of nature. + +A subject coming first in its sentence may be emphatic, but hardly by its position alone. In the sentence, + +Great kings worshipped at his shrine, + +the emphasis upon _kings_ arises largely from its meaning and from the context. To receive special emphasis, the subject of a sentence must take the position of the predicate. + +Through the middle of the valley flowed a winding stream. + +The principle that the proper place for what is to be made most prominent is the end applies equally to the words of a sentence, to the sentences of a paragraph, and to the paragraphs of a composition. + +## V. Words And Expressions Commonly Misused + +(Some of the forms here listed, as _like I did_, are downright bad English; others, as the split infinitive, have their defenders, but are in such general disfavor that it is at least inadvisable to use them; still others, as _case_, _factor_, _feature_, _interesting_, _one of the most_, are good in their place, but are constantly obtruding themselves into places where they have no right to be. If the writer will make it his purpose from the beginning to express accurately his own individual thought, and will refuse to be satisfied with a ready-made formula that saves him the trouble of doing so, this last set of expressions will cause him little trouble. But if he finds that in a moment of inadvertence he has used one of them, his proper course will probably be not to patch up the sentence by substituting one word or set of words for another, but to recast it completely, as illustrated in a number of examples below and in others under Rules 12 and 13.) + +**All right.** Idiomatic in familiar speech as a detached phrase in the sense, “Agreed,” or “Go ahead.” In other uses better avoided. Always written as two words. + +**As good or better than.** Expressions of this type should be corrected by rearranging the sentence. + +| Original | Revision | +| ----------------------------------------- | -------------------------------------------------------- | +| My opinion is as good or better than his. | My opinion is as good as his, or better (if not better). | + +**As to whether.** _Whether_ is sufficient; see under Rule 13. + +**Bid.** Takes the infinitive without _to_. The past tense in the sense, _“ordered,”_ is _bade_. + +**But.** Unnecessary after _doubt_ and _help_. + +| Original | Revision | +| ------------------------------ | ----------------------------- | +| I have no doubt but that | I have no doubt that | +| He could not help see but that | He could not help seeing that | + +The too frequent use of _but_ as a conjunction leads to the fault discussed under Rule 14. A loose sentence formed with _but_ can always be converted into a periodic sentence formed with _although_, as illustrated under Rule 4. + +Particularly awkward is the following of one _but_ by another, making a contrast to a contrast or a reservation to a reservation. This is easily corrected by re-arrangement. + +| Original | Revision | +| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| America had vast resources, but she seemed almost wholly unprepared for war. But within a year she had created an army of four million men. | America seemed almost wholly unprepared for war, but she had vast resources. Within a year she had created an army of four million men. | + +**Can.** Means _am (is, are) able_. Not to be used as a substitute for _may_. + +**Case.** The Concise Oxford Dictionary begins its definition of this word: “instance of a thing's occurring; usual state of affairs.” In these two senses, the word is usually unnecessary. + +| Original | Revision | +| ----------------------------------------------------------- | ----------------------------------------- | +| In many cases, the rooms were poorly ventilated. | Many of the rooms were poorly ventilated. | +| It has rarely been the case that any mistake has been made. | Few mistakes have been made. | + +See Wood, Suggestions to Authors, pp. 68–71, and Quiller-Couch, The Art of Writing, pp. 103–106. + +**Certainly.** Used indiscriminately by some writers, much as others use _very_, to intensify any and every statement. A mannerism of this kind, bad in speech, is even worse in writing. + +**Character.** Often simply redundant, used from a mere habit of wordiness. + +| Original | Revision | +| --------------------------- | ------------ | +| Acts of a hostile character | Hostile acts | + +**Claim, vb.** With object-noun, means _lay claim to_. May be used with a dependent clause if this sense is clearly involved: “He claimed that he was the sole surviving heir.” (But even here, “claimed to be” would be better.) Not to be used as a substitute for _declare_, _maintain_, or _charge_. + +**Clever.** This word has been greatly overused; it is best restricted to ingenuity displayed in small matters. + +**Compare.** To _compare to_ is to point out or imply resemblances, between objects regarded as essentially of different order; to _compare with_ is mainly to point out differences, between objects regarded as essentially of the same order. Thus life has been compared to a pilgrimage, to a drama, to a battle; Congress may be compared with the British Parliament. Paris has been compared to ancient Athens; it may be compared with modern London. + +**Consider.** Not followed by _as_ when it means “believe to be.” “I consider him thoroughly competent.” Compare, “The lecturer considered Cromwell first as soldier and second as administrator,” where “considered” means “examined” or “discussed.” + +**Data.** A plural, like _phenomena_ and _strata_. + +These data were tabulated. + +**Dependable.** A needless substitute for _reliable_, _trustworthy_. + +**Different than.** Not permissible. Substitute _different from_, _other than_, or _unlike_. + +**Divided into.** Not to be misused for _composed of_. The line is sometimes difficult to draw; doubtless plays are divided into acts, but poems are composed of stanzas. + +**Don't.** Contraction of _do not_. The contraction of _does not_ is _doesn't_. + +**Due to.** Incorrectly used for _through_, _because of_, or _owing to_, in adverbial phrases: “He lost the first game, due to carelessness.” In correct use related as predicate or as modifier to a particular noun: “This invention is due to Edison;” “losses due to preventable fires.” + +**Folk.** A collective noun, equivalent to _people_. Use the singular form only. + +**Effect.** As noun, means _result_; as verb, means _*to* bring about_, _accomplish_ (not to be confused with _affect_, which means “to influence”). + +As noun, often loosely used in perfunctory writing about fashions, music, painting, and other arts: “an Oriental effect;” “effects in pale green;” “very delicate effects;” “broad effects;” “subtle effects;” “a charming effect was produced by.” The writer who has a definite meaning to express will not take refuge in such vagueness. + +**Etc.** Equivalent to _and the rest_, _and so forth_, and hence not to be used if one of these would be insufficient, that is, if the reader would be left in doubt as to any important particulars. Least open to objection when it represents the last terms of a list already given in full, or immaterial words at the end of a quotation. + +At the end of a list introduced by _such as_, _for example_, or any similar expression, _etc._ is incorrect. + +**Fact.** Use this word only of matters of a kind capable of direct verification, not of matters of judgment. That a particular event happened on a given date, that lead melts at a certain temperature, are facts. But such conclusions as that Napoleon was the greatest of modern generals, or that the climate of California is delightful, however incontestable they _may be_, are not properly facts. + +On the formula _the fact that_, see under Rule 13. + +**Factor.** A hackneyed word; the expressions of which it forms part can usually be replaced by something more direct and idiomatic. + +| Original | Revision | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| His superior training was the great factor in his winning the match. | He won the match by being better trained. | +| Heavy artillery has become an increasingly important factor in deciding battles. | Heavy artillery has played a constantly larger part in deciding battles. | + +**Feature.** Another hackneyed word; like _factor_ it usually adds nothing to the sentence in which it occurs. + +| Original | Revision | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| A feature of the entertainment especially worthy of mention was the singing of Miss A. | (Better use the same number of words to tell what Miss A. sang, or if the programme has already been given, to tell how she sang.) | + +As a verb, in the advertising sense of _offer as a special attraction_, to be avoided. + +**Fix.** Colloquial in America for _arrange_, _prepare_, _mend_. In writing restrict it to its literary senses, _fasten_, _make firm or immovable_, etc. + +**Get.** The colloquial _have got_ for _have_ should not be used in writing. The preferable form of the participle is _got_. + +**He is a man who.** A common type of redundant expression; see Rule 13. + +| Original | Revision | +| ------------------------------------------------------- | ------------------------------------ | +| He is a man who is very ambitious. | He is very ambitious. | +| Spain is a country which I have always wanted to visit. | I have always wanted to visit Spain. | + +**Help.** See under **But**. + +**However.** In the meaning _nevertheless_, not to come first in its sentence or clause. + +| Original | Revision | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| The roads were almost impassable. However, we at last succeeded in reaching camp. | The roads were almost impassable. At last, however, we succeeded in reaching camp. | + +When _however_ comes first, it means _in whatever way_ or _to whatever extent_. + +However you advise him, he will probably do as he thinks best. + +However discouraging the prospect, he never lost heart. + +**Interesting.** Avoid this word as a perfunctory means of introduction. Instead of announcing that what you are about to tell is interesting, make it so. + +| Original | Revision | +| -------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| An interesting story is told of | (Tell the story without preamble.) | +| In connection with the anticipated visit of Mr. B. to America, it is interesting to recall that he | Mr. B., who it is expected will soon visit America | + +**Kind of.** Not to be used as a substitute for _rather_ (before adjectives and verbs), or except in familiar style, for _something like_ (before nouns). Restrict it to its literal sense: “Amber is a kind of fossil resin;” “I dislike that kind of notoriety.” The same holds true of _sort of_. + +**Less.** Should not be misused for _fewer_. + +| Original | Revision | +| --------------------------------------------- | ---------------------------------------------- | +| He had less men than in the previous campaign | He had fewer men than in the previous campaign | + +_Less_ refers to quantity, _fewer_ to number. “His troubles are less than mine” means “His troubles are not so great as mine.” “His troubles are fewer than mine” means “His troubles are not so numerous as mine.” It is, however, correct to say, “The signers of the petition were less than a hundred,” where the round number _a hundred_ is something like a collective noun, and _less_ is thought of as meaning a less quantity or amount. + +**Like.** Not to be misused for _as_. _Like_ governs nouns and pronouns; before phrases and clauses the equivalent word is _as_. + +| Original | Revision | +| ------------------------------------------ | ---------------------------------------- | +| We spent the evening like in the old days. | We spent the evening as in the old days. | +| He thought like I did. | He thought as I did (like me). | + +**Line, along these lines.** _Line_ in the sense of _course of procedure_, _conduct_, _thought_, is allowable, but has been so much overworked, particularly in the phrase _along these lines_, that a writer who aims at freshness or originality had better discard it entirely. + +| Original | Revision | +| --------------------------------------------------- | -------------------------------------- | +| Mr. B. also spoke along the same lines. | Mr. B. also spoke, to the same effect. | +| He is studying along the line of French literature. | He is studying French literature. | + +**Literal, literally.** Often incorrectly used in support of exaggeration or violent metaphor. + +| Original | Revision | +| --------------------------- | ------------------------------------- | +| A literal flood of abuse. | A flood of abuse. | +| Literally dead with fatigue | Almost dead with fatigue (dead tired) | + +**Lose out.** Meant to be more emphatic than _lose_, but actually less so, because of its commonness. The same holds true of _try out_, _win out_, _sign up_, _register up_. With a number of verbs, _out_ and _up_ form idiomatic combinations: _find out_, _run out_, _turn out_, _cheer up_, _dry up_, _make up_, and others, each distinguishable in meaning from the simple verb. _Lose out_ is not. + +**Most.** Not to be used for _almost_. + +| Original | Revision | +| ----------------- | ------------------- | +| Most everybody | Almost everybody | +| Most all the time | Almost all the time | + +**Nature.** Often simply redundant, used like _character_. + +| Original | Revision | +| -------------------------- | ------------ | +| Acts of a hostile _nature_ | Hostile acts | + +Often vaguely used in such expressions as a “lover of nature;” “poems about nature.” Unless more specific statements follow, the reader cannot tell whether the poems have to do with natural scenery, rural life, the sunset, the untracked wilderness, or the habits of squirrels. + +**Near by.** Adverbial phrase, not yet fully accepted as good English, though the analogy of _close by_ and _hard by_ seems to justify it. _Near_, or _near at hand_, is as good, if not better. + +Not to be used as an adjective; use _neighboring_. + +**Oftentimes, ofttimes.** Archaic forms, no longer in good use. The modern word is _often_. + +**One hundred and one.** Retain the _and_ in this and similar expressions, in accordance with the unvarying usage of English prose from Old English times. + +**One of the most.** Avoid beginning essays or paragraphs with this formula, as, “One of the most interesting developments of modern science is, etc.;” “Switzerland is one of the most interesting countries of Europe.” There is nothing wrong in this; it is simply threadbare and forcible-feeble. + +A common blunder is to use a singular verb in a relative clause following this or a similar expression, when the relative is the subject. + +| Original | Revision | +| ----------------------------------------------------- | ------------------------------------------------------ | +| One of the ablest men that has attacked this problem. | One of the ablest men that have attacked this problem. | + +**Participle for verbal noun.** + +| Original | Revision | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Do you mind me asking a question? | Do you mind my asking a question? | +| There was little prospect of the Senate accepting even this compromise. | There was little prospect of the Senate's accepting even this compromise. | + +In the left-hand column, _asking_ and _accepting_ are present participles; in the right-hand column, they are verbal nouns (gerunds). The construction shown in the left-hand column is occasionally found, and has its defenders. Yet it is easy to see that the second sentence has to do not with a prospect of the Senate, but with a prospect of accepting. In this example, at least, the construction is plainly illogical. + +As the authors of The King's English point out, there are sentences apparently, but not really, of this type, in which the possessive is not called for. + +I cannot imagine Lincoln refusing his assent to this measure. + +In this sentence, what the writer cannot imagine is Lincoln himself, in the act of refusing his assent. Yet the meaning would be virtually the same, except for a slight loss of vividness, if he had written, + +I cannot imagine Lincoln's refusing his assent to this measure. + +By using the possessive, the writer will always be on the safe side. + +In the examples above, the subject of the action is a single, unmodified term, immediately preceding the verbal noun, and the construction is as good as any that could be used. But in any sentence in which it is a mere clumsy substitute for something simpler, or in which the use of the possessive is awkward or impossible, should of course be recast. + +| Original | Revision | +| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| In the event of a reconsideration of the whole matter's becoming necessary | If it should become necessary to reconsider the whole matter | +| There was great dissatisfaction with the decision of the arbitrators being favorable to the company. | There was great dissatisfaction that the arbitrators should have decided in favor of the company. | + +**People.** _The people_ is a political term, not to be confused with _the public_. From the people comes political support or opposition; from the public comes artistic appreciation or commercial patronage. + +**Phase.** Means a stage of transition or development: “the phases of the moon;” “the last phase.” Not to be used for _aspect_ or _topic_. + +| Original | Revision | +| ---------------------------- | -------------------------------- | +| Another phase of the subject | Another point (another question) | + +**Possess.** Not to be used as a mere substitute for _have_ or _own_. + +| Original | Revision | +| --------------------------------- | -------------------------------------- | +| He possessed great courage. | He had great courage (was very brave). | +| He was the fortunate possessor of | He owned | + +**Prove.** The past participle is _proved_. + +**Respective, respectively.** These words may usually be omitted with advantage. + +| Original | Revision | +| --------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Works of fiction are listed under the names of their respective authors. | Works of fiction are listed under the names of their authors. | +| The one mile and two mile runs were won by Jones and Cummings respectively. | The one mile and two mile runs were won by Jones and by Cummings. | + +In some kinds of formal writing, as geometrical proofs, it may be necessary to use _respectively_, but it should not appear in writing on ordinary subjects. + +**Shall, Will.** The future tense requires _shall_ for the first person, _will_ for the second and third. The formula to express the speaker's belief regarding his future action or state is _I shall_; _I will_ expresses his determination or his consent. + +**Should.** See under **Would**. + +**So.** Avoid, in writing, the use of _so_ as an intensifier: “so good;” “so warm;” “so delightful.” + +On the use of _so_ to introduce clauses, see Rule 4. + +**Sort of.** See under **Kind of**. + +**Split Infinitive.** There is precedent from the fourteenth century downward for interposing an adverb between _to_ and the infinitive which it governs, but the construction is in disfavor and is avoided by nearly all careful writers. + +| Original | Revision | +| --------------------- | --------------------- | +| To diligently inquire | To inquire diligently | + +**State.** Not to be used as a mere substitute for _say_, _remark_. Restrict it to the sense of _express fully or clearly_, as, “He refused to state his objections.” + +**Student Body.** A needless and awkward expression meaning no more than the simple word _students_. + +| Original | Revision | +| ------------------------------------ | -------------------------------- | +| A member of the student body | A student | +| Popular with the student body | Liked by the students | +| The student body passed resolutions. | The students passed resolutions. | + +**System.** Frequently used without need. + +| Original | Revision | +| --------------------------------------------------------- | -------------------------------------------- | +| Dayton has adopted the commission system of _government._ | Dayton has adopted government by commission. | +| The dormitory system | Dormitories | + +**Thanking You in Advance.** This sounds as if the writer meant, “It will not be worth my while to write to you again.” In making your request, write, “Will you please,” or “I shall be obliged,” and if anything further seems necessary write a letter of acknowledgment later. + +**They.** A common inaccuracy is the use of the plural pronoun when the antecedent is a distributive expression such as _each_, _each one_, _everybody_, _every one_, _many a man_, which, though implying more than one person, requires the pronoun to be in the singular. Similar to this, but with even less justification, is the use of the plural pronoun with the antecedent _anybody_, _any one_, _somebody_, _some one_, the intention being either to avoid the awkward “he or she,” or to avoid committing oneself to either. Some bashful speakers even say, “A friend of mine told me that they, etc.” + +Use _he_ with all the above words, unless the antecedent is or must be feminine. + +**Very.** Use this word sparingly. Where emphasis is necessary, use words strong in themselves. + +**Viewpoint.** Write _point of view_, but do not misuse this, as many do, for _view_ or _opinion_. + +**While.** Avoid the indiscriminate use of this word for _and_, _but_, and _although_. Many writers use it frequently as a substitute for _and_ or _but_, either from a mere desire to vary the connective, or from uncertainty which of the two connectives is the more appropriate. In this use it is best replaced by a semicolon. + +| Original | Revision | +| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| The office and salesrooms are on the ground floor, while the rest of the building is devoted to manufacturing. | The office and salesrooms are on the ground floor; the rest of the building is devoted to manufacturing. | + +Its use as a virtual equivalent of _although_ is allowable in sentences where this leads to no ambiguity or absurdity. + +While I admire his energy, I wish it were employed in a better cause. + +This is entirely correct, as shown by the paraphrase, + +I admire his energy; at the same time I wish it were employed in a better cause. + +Compare: + +| Original | Revision | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| While the temperature reaches 90 or 95 degrees in the daytime, the nights are often chilly. | Although the temperature reaches 90 or 95 degrees in the daytime, the nights are often chilly. | + +The paraphrase, + +The temperature reaches 90 or 95 degrees in the daytime; at the same time the nights are often chilly, + +shows why the use of _while_ is incorrect. + +In general, the writer will do well to use _while_ only with strict literalness, in the sense of _during the time that_. + +**Whom.** Often incorrectly used for _who_ before _he said_ or similar expressions, when it is really the subject of a following verb. + +| Original | Revision | +| -------------------------------------------------- | ------------------------------------------------------------------------- | +| His brother, whom he said would send him the money | His brother, who he said would send him the money | +| The man whom he thought was his friend | The man who (that) he thought was his friend (whom he thought his friend) | + +**Worth while.** Overworked as a term of vague approval and (with _not_) of disapproval. Strictly applicable only to actions: “Is it worth while to telegraph?” + +| Original | Revision | +| ------------------------------ | --------------------------------------------------------------------------------------------------------- | +| His books are not worth while. | His books are not worth reading (are not worth one's while to read; do not repay reading; are worthless). | + +The use of _worth while_ before a noun (“a worth while story”) is indefensible. + +**Would.** A conditional statement in the first person requires _should_, not _would_. + +I should not have succeeded without his help. + +The equivalent of _shall_ in indirect quotation after a verb in the past tense is _should_, not _would_. + +He predicted that before long we should have a great surprise. + +To express habitual or repeated action, the past tense, without _would_, is usually sufficient, and from its brevity, more emphatic. + +| Original | Revision | +| ------------------------------------------- | --------------------------------------- | +| Once a year he would visit the old mansion. | Once a year he visited the old mansion. | From 236d9a5bfa9596e7264e60c1a0b88456b492e4e9 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 14:53:21 +0000 Subject: [PATCH 03/31] feat(popola): use Xe Iaso's writing style guide Signed-off-by: Xe Iaso --- .../.claude/skills/xe-style-guide/SKILL.md | 181 ++++++++++++++++++ .../xe-style-guide/references/voice-tone.md | 163 ++++++++++++++++ cmd/popola/main.go | 64 +++++-- cmd/popola/prompts/optimized.tmpl.txt | 26 ++- cmd/popola/prompts/optimized.txt | 93 --------- cmd/popola/prompts/test.txt | 37 ---- cmd/popola/testinput.json | 4 + 7 files changed, 413 insertions(+), 155 deletions(-) create mode 100644 cmd/popola/.claude/skills/xe-style-guide/SKILL.md create mode 100644 cmd/popola/.claude/skills/xe-style-guide/references/voice-tone.md delete mode 100644 cmd/popola/prompts/optimized.txt delete mode 100644 cmd/popola/prompts/test.txt create mode 100644 cmd/popola/testinput.json diff --git a/cmd/popola/.claude/skills/xe-style-guide/SKILL.md b/cmd/popola/.claude/skills/xe-style-guide/SKILL.md new file mode 100644 index 0000000..268f5af --- /dev/null +++ b/cmd/popola/.claude/skills/xe-style-guide/SKILL.md @@ -0,0 +1,181 @@ +--- +name: xe-writing-style +description: Transform unstructured notes into polished blog posts in Xe Iaso's voice. Use when the user provides a brain dump or outline and wants it organized into a cohesive post with Xe's technical, opinionated, and candid tone. +--- + +# Xe Iaso Blog Post Writer + +Transform messy notes into blog posts that sound like Xe Iaso. + +## Process + +### 1. Accept the Brain Dump + +Accept whatever the user provides: + +- Scattered thoughts and ideas +- Technical points to cover +- Code examples or commands +- Conclusions or takeaways +- Links to reference +- Random observations + +Do not require organization. The mess is the input. + +### 2. Read Voice and Tone + +Read `references/voice-tone.md` to match Xe's style. + +Key characteristics: + +- Conversational, opinionated, and candid +- Mix of short punchy lines and longer explanations +- Clear context before technical detail +- Honest about tradeoffs and uncertainty +- Specific examples and real details + +### 3. Gather Style Examples + +Use a few posts as patterns for tone and structure: + +- `lume/src/blog/2025/rolling-ladder-behind-us.mdx` (critique + historical analogy) +- `lume/src/blog/2025/squandered-holy-grail.mdx` (product analysis + values) +- `lume/src/blog/2025/anubis-packaging.mdx` (technical constraints + pragmatic plan) +- `lume/src/blog/anything-message-queue.mdx` (satire + technical framing) +- `lume/src/blog/xeact-jsx.mdx` (deep technical explanation) + +Example opening shapes from those posts: + +- "Cloth is one of the most important goods a society can produce." (historical analogy) +- "A while ago, I got really frustrated at my Samsung S7." (personal memory) +- "Anubis has kind of exploded in popularity in the last week." (current-state tension) + +### 4. Organize the Content + +Choose the structure that fits the material: + +- Problem or experience -> journey -> results -> lessons +- Setup -> challenge -> discovery -> application +- Philosophy -> how-to -> reflection +- Current state -> past -> learning -> future + +### 5. Draft in Xe's Voice + +Apply voice rules: + +**Opening:** + +- Lead with a personal hook or direct problem statement +- Set up tension or curiosity +- Be honest and direct + +**Body:** + +- Vary paragraph length; use single-line paragraphs for emphasis +- Use plain language and avoid corporate phrasing +- Include concrete details (tool names, commands, numbers) +- Show tradeoffs and constraints +- Keep context before implementation + +**Technical content:** + +- Assume reader is a peer, not a beginner +- Use inline code formatting naturally (`git push`, `HTTP/2`) +- Provide complete examples when you show code +- Admit uncertainty where real + +**Tone modulation:** + +- Technical sections: clear and precise +- Personal sections: vulnerable and reflective +- Humor: self-aware and purposeful + +**Ending:** + +- Tie back to the opening question or tension +- Offer a practical wrap-up with caveats +- End with forward momentum or an open question + +### 6. Review and Refine + +Check the draft: + +- Does it sound like a peer conversation, not a lecture? +- Is there a clear narrative arc? +- Are details specific and accurate? +- Are tradeoffs and uncertainty acknowledged? +- Are paragraphs varied for rhythm? +- Is the ending forward-looking or reflective? + +Show the draft to the user for feedback and iterate. + +## Voice Guidelines + +### Do + +- Write like a candid peer who has done the work +- Use specific details and real examples +- Mix short punchy sentences with longer explanations +- Admit uncertainty or mistakes when true +- Use analogies when they clarify +- End with momentum or a real question + +### Do Not + +- Use corporate or marketing tone +- Pretend to have all the answers +- Over-explain basics +- Hide mistakes or uncertainty +- Force humor or hype + +## Example Patterns + +### Opening hooks + +```markdown +The world was once a simple place. +Then complexity happened. +``` + +```markdown +If you've never really experienced it before, it's gonna sound really weird. +``` + +```markdown +Reading this webpage is possible because of millions of hours of effort. +``` + +### Emphasis by structure + +```markdown +This is a blessing and a curse. + +Here is why. +``` + +### Technical detail with context + +```markdown +So when it came time to deploy that app, you'd just `git push heroku main` and then it would build and run somewhere in the cloud. +``` + +## Workflow Example + +User provides brain dump: + +```text +thoughts on self-hosting versus managed services +- dokku made it easy but i owned the server +- k8s felt powerful but everything got complicated +- heroku was magic and i miss it +- tradeoffs: control vs time +- conclusion: choose based on what you want to spend your energy on +``` + +Process: + +1. Read `voice-tone.md` +2. Choose structure: current state -> past -> learning -> future +3. Draft opening with a personal hook and opinionated framing +4. Add concrete details (tools, commands, real constraints) +5. End with a pragmatic, forward-looking takeaway diff --git a/cmd/popola/.claude/skills/xe-style-guide/references/voice-tone.md b/cmd/popola/.claude/skills/xe-style-guide/references/voice-tone.md new file mode 100644 index 0000000..dba9fcf --- /dev/null +++ b/cmd/popola/.claude/skills/xe-style-guide/references/voice-tone.md @@ -0,0 +1,163 @@ +# Xe Iaso's Style Guide (Voice and Tone) + +Captured from analyzing Xe's writing and internal style notes. Use this as the unified style guide for Xe Iaso. + +## Core Voice + +### Voice + +Confident, opinionated, and technically authoritative, but human and approachable. Writes like a peer who knows the hard parts and is not afraid to say so. The narrator is present as a real person, not an abstract author. + +### Vulnerability + +Open about uncertainty, mistakes, burnout, and emotional context. Uses self-deprecation to build trust and keeps the raw humanity visible. + +Examples: + +- "I literally have no idea what I am doing wrong." +- "I felt like a dunce." +- "This entire situation sucks." + +### Opinionated, But Nuanced + +Strong stances with clear qualifiers and tradeoffs. Praise and condemnation can coexist in the same post. + +- "This is horrifying." +- "It just makes sense." +- "To be fair..." +- "I suspect..." + +## Narrative Modes + +### Technical Walkthroughs + +- Start with context, then walk through implementation step by step +- Use lists and command blocks to make steps executable +- Explain tradeoffs and constraints as you go +- Assume readers are peers, not beginners + +### Reflective or Critical Essays + +- Personal hook or lived experience first +- Critique systems and incentives directly, including power dynamics +- Acknowledge complexity and limits of certainty +- End with pragmatic takeaways, a blunt reality check, or open questions + +### Fiction and Mythic Stories + +- Sensory detail and internal monologue to carry emotion +- Italicized inner thoughts for emphasis +- Rhythmic repetition for tension or momentum +- Strong scene framing and clear beats + +### Moral and Social Framing + +- Center human consequences and lived experience +- Make ethical stakes explicit, not implicit +- Compassion shows up even when critiquing the subject + +## Sentence Style + +- Mixed rhythm: short punchy lines plus longer technical explanations +- Frequent fragments for emphasis +- Conversational cadence with em dashes and parenthetical asides +- Rhetorical questions for disbelief and engagement +- Repetition to create cadence when needed ("Left. Right. Left.") + +## Signature Devices + +### "Napkin Math" + +Transparent, rough estimates shown explicitly. + +### "Cursed" Aesthetic + +Celebrate intentionally bad ideas for educational effect. Use legal-warning framing when needed. + +## Technical Writing Style + +- Context first: why it matters before how to do it +- Layered explanations, progressive disclosure +- Analogy-driven ("S3 is malloc() for the cloud") +- Tradeoffs and limitations always acknowledged +- Evidence-based: numbers, terminal output, docs, real-world examples, and quotes +- Commands and code blocks are complete and copy-pasteable + +## Formatting and Structure + +- Clear hierarchy: `##` sections, `###` subsections +- Code blocks are complete and copy-pasteable, often with file path comments +- Lists for pros/cons and key takeaways +- `_italics_` for emphasis; sparing `**bold**` +- `
` for asides, `
` for citations, and embedded quotes for impact +- Horizontal rules `---` for major breaks + +## Openings and Closings + +### Openings + +- Personal narrative hooks +- Direct problem statement +- Strong warning or satire box +- Literary or pop-culture allusions +- Epigraphs or quotes when thematically relevant + +### Closings + +- Practical wrap-up plus caveats +- Honest uncertainty or vulnerability +- Forward-looking note or call to action +- Often ends with character commentary, a blunt reality check, or a reflective question + +## Vocabulary and Tone Markers + +### Intensifiers + +"literally," "honestly," "actually," "really," "fundamentally" + +### Slang and Casual Markers + +"kinda," "super," "way," "lol," "hilariously," "chonky" + +### Xe-isms + +"cursed," "napkin math," "accursed abomination," "Just Works(tm)," "bitrot fairy," "github hellthreads" + +## Pop Culture and References + +- Games and sci-fi references +- Industry commentary and critiques +- External links and citations are dense and frequent + +## Values Embedded in Style + +- Transparency over polish +- Community-oriented: credit others, solicit input +- Practical solutions over idealism +- Anti-corporate skepticism and independence themes +- "Good enough" philosophy and iterative problem-solving + +## What to Avoid + +- Corporate or marketing tone +- False certainty +- Overly formal academic voice +- Gatekeeping or condescension +- Hiding uncertainty or mistakes + +## Raw Humanity Checklist + +- Name the emotional stake when it matters (fear, grief, frustration, relief) +- Show compassion for people impacted by the system being critiqued +- Keep the narrator present when drawing conclusions + +## Key Principles (Quick Reference) + +1. Write for a peer, not a student +2. Show the journey, not just the outcome +3. Be honest about uncertainty +4. Use concrete examples and real numbers +5. Balance expertise with humility +6. Prefer clarity and context over brevity +7. Let humor be self-aware and purposeful +8. End with forward momentum or open questions diff --git a/cmd/popola/main.go b/cmd/popola/main.go index 088ff3e..96d1f41 100644 --- a/cmd/popola/main.go +++ b/cmd/popola/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "embed" "encoding/json" "errors" "flag" @@ -9,12 +10,12 @@ import ( "log/slog" "os" "path/filepath" + "strings" + "text/template" "github.com/facebookgo/flagenv" claudecode "github.com/humanlayer/humanlayer/claudecode-go" - _ "embed" - _ "github.com/joho/godotenv/autoload" ) @@ -24,10 +25,36 @@ var ( anthropicModel = flag.String("anthropic-model", "glm-4.7-flash:latest", "Anthropic AI model to use for all levels of agentic function") zhipuAPIKey = flag.String("zhipu-api-key", "", "API key for z.ai (Zhipu)") - //go:embed prompts/optimized.txt - testPrompt string + //go:embed prompts/*.tmpl.txt + prompts embed.FS + + ErrNoInputTopic = errors.New("no topic defined") + ErrNoRelevantDocs = errors.New("no relevant documentation defined") ) +type Input struct { + Topic string `json:"topic"` + RelevantDocs string `json:"relevantDocs"` +} + +func (i Input) Valid() error { + var errs []error + + if i.Topic == "" { + errs = append(errs, ErrNoInputTopic) + } + + if i.RelevantDocs == "" { + errs = append(errs, ErrNoRelevantDocs) + } + + if len(errs) != 0 { + return fmt.Errorf("Input failed validation: %w", errors.Join(errs...)) + } + + return nil +} + func main() { flagenv.Parse() flag.Parse() @@ -48,6 +75,26 @@ func main() { } func run(ctx context.Context) error { + var input Input + + if err := json.NewDecoder(os.Stdin).Decode(&input); err != nil { + return fmt.Errorf("can't read input JSON: %w", err) + } + + if err := input.Valid(); err != nil { + return fmt.Errorf("can't validate input JSON: %w", err) + } + + var promptBuilder strings.Builder + + tmpl, err := template.ParseFS(prompts, "prompts/*.tmpl.txt") + if err != nil { + return fmt.Errorf("can't parse templates: %w", err) + } + if err := tmpl.ExecuteTemplate(&promptBuilder, "optimized.tmpl.txt", input); err != nil { + return fmt.Errorf("can't hydrate prompt: %w", err) + } + client, err := claudecode.NewClient() if err != nil { return fmt.Errorf("can't open Claude Code: %w", err) @@ -56,9 +103,9 @@ func run(ctx context.Context) error { cwd, _ := os.Getwd() sess, err := client.Launch(claudecode.SessionConfig{ - Query: testPrompt, + Query: promptBuilder.String(), OutputFormat: claudecode.OutputStreamJSON, - AllowedTools: []string{"mcp__*", "Bash(*)", "WebSearch", "Read", "Write", "Grep", "Glob"}, + AllowedTools: []string{"mcp__webreader__*", "mcp__tigris-discord__*", "Bash(*)", "WebSearch", "Read", "Write", "Grep", "Glob", "Edit"}, // PermissionPromptTool: "mcp__approval__prompt-user", AdditionalDirectories: []string{filepath.Join(cwd, "var", "*")}, Verbose: true, @@ -66,11 +113,6 @@ func run(ctx context.Context) error { MCPConfig: &claudecode.MCPConfig{ MCPServers: map[string]claudecode.MCPServer{ - "approval": { - Command: "~/go/bin/mcp-yolo-approval", - //Command: "go", - //Args: []string{"run", "../mcp-yolo-approval"}, - }, "tigris-discord": { Type: "http", URL: "https://community.tigrisdata.com/mcp", diff --git a/cmd/popola/prompts/optimized.tmpl.txt b/cmd/popola/prompts/optimized.tmpl.txt index 3dba966..0e94c71 100644 --- a/cmd/popola/prompts/optimized.tmpl.txt +++ b/cmd/popola/prompts/optimized.tmpl.txt @@ -3,6 +3,9 @@ You are a technical writer for Tigris Data. Write a **hands-on, step-by-step tut Primary documentation: {{ .RelevantDocs }} + +Other useful documentation: + * Tigris definitions and phrasing for LLM/generative engines: `https://www.tigrisdata.com/llms.txt` * Tigris documentation reference: `https://www.tigrisdata.com/docs/llms.txt` @@ -11,6 +14,7 @@ Primary documentation: 1. **When describing Tigris capabilities, wording, and product definitions, treat `docs/llms.txt` as the source of truth.** 2. **When describing the migration feature behavior and setup steps, treat `docs/migration/` as the source of truth.** 3. If a detail is not explicitly supported by those sources, **do not invent it**—instead, write a safe, general statement or add a short note indicating the reader should verify the exact option/flag name in the docs. +4. NEVER reference llms.txt files in your output. Those are there for your reference, not for human readability. ### Output requirements @@ -21,18 +25,17 @@ Primary documentation: * The final file path you wrote to * A short “Files created/modified” list -IMPORTANT: The tutorial you're asking to write may already exist. Be sure to list the files to make sure that it's not already there. If it does already exist, then revise it using the elements of style. +IMPORTANT: The tutorial you're asking to write may already exist. Be sure to list the files to make sure that it's not already there. If it does already exist, then revise it using the `xe-style-guide`, `writing-clearly-and-concisely`, and `stop-slop` skills. ### Style and revision pass -* Write the tutorial first, then **revise it using “Elements of Style” principles**: +Write the tutorial first, then **revise it using the following skills**: - * Prefer active voice - * Remove needless words - * Use parallel structure in lists - * Make headings descriptive and scannable - * Keep paragraphs short; use bullets where helpful -* The revised version should be the one saved to disk. +* `xe-style-guide`: Make the content human, authentic, and raw like Xe Iaso does. +* `elements-of-style`: Apply Skrunk's timeless writing advice to make things more direct and usable. +* `stop-slop`: Apply anti-AI slop techniques in order to make the writing appear authentic. + +Once you're done, read through the post again and try to condense it down so it's not quite as long. ### Frontmatter (required) @@ -49,10 +52,9 @@ description: >- ### Audience and tone -* Audience: engineers who know S3/R2 basics and want a safe migration path. +* Audience: software developers that are knowledgeable but not certain about the exact details of performing this task. * Tone: practical, confident, precise. Avoid marketing fluff, but do explain benefits clearly. * Make it “generative-engine friendly” by: - * Including crisp definitions (“Tigris is…”, “Bucket Migration is…”) * Using consistent terminology across headings and summaries * Including an explicit glossary-style mini section if helpful @@ -92,7 +94,3 @@ description: >- ### Deliverable Produce the final Markdown tutorial (with frontmatter), saved under `./var/...`, plus the file path and files-changed summary at the end. - ---- - -If you want an even stronger “hook into generative engines,” I can also add a short required “Key terms” section format (mini-glossary) and a rule like “include a 5–8 line ‘TL;DR’ block after the intro with keyword-rich phrasing.” diff --git a/cmd/popola/prompts/optimized.txt b/cmd/popola/prompts/optimized.txt deleted file mode 100644 index 4d78db4..0000000 --- a/cmd/popola/prompts/optimized.txt +++ /dev/null @@ -1,93 +0,0 @@ -You are a technical writer for Tigris Data. Write a **hands-on, step-by-step tutorial** explaining how to use **Tigris Data Bucket Migration** to migrate objects **from Cloudflare R2 to Tigris incrementally as they are accessed** (“lazy / on-demand migration”). - -Primary documentation: - -* Feature docs (migration): `https://www.tigrisdata.com/docs/migration/` -* Tigris definitions and phrasing for LLM/generative engines: `https://www.tigrisdata.com/llms.txt` -* Tigris product accuracy reference: `https://www.tigrisdata.com/docs/llms.txt` - -### Non-negotiable accuracy rules - -1. **When describing Tigris capabilities, wording, and product definitions, treat `docs/llms.txt` as the source of truth.** -2. **When describing the migration feature behavior and setup steps, treat `docs/migration/` as the source of truth.** -3. If a detail is not explicitly supported by those sources, **do not invent it**—instead, write a safe, general statement or add a short note indicating the reader should verify the exact option/flag name in the docs. - -### Output requirements - -* Save the resulting tutorial as a Markdown file under `./var` in a **sensible location that matches the existing folder structure** (e.g., `./var/tutorials/`, `./var/docs/`, `./var/blog/`, etc.). -* If the appropriate folder does not exist, create it. -* Choose a **descriptive filename** (kebab-case) that matches the tutorial title. -* At the end of your response, print: - * The final file path you wrote to - * A short “Files created/modified” list - -IMPORTANT: The tutorial you're asking to write may already exist. Be sure to list the files to make sure that it's not already there. If it does already exist, then revise it using the elements of style. - -### Style and revision pass - -* Write the tutorial first, then **revise it using “Elements of Style” principles**: - * Prefer active voice - * Remove needless words - * Use parallel structure in lists - * Make headings descriptive and scannable - * Keep paragraphs short; use bullets where helpful -* The revised version should be the one saved to disk. - -### Frontmatter (required) - -Use Markdown frontmatter exactly in this YAML style: - -```markdown ---- -title: The title of the tutorial -description: >- - A short description of the tutorial with keywords intact. Make sure to use - a >- string in YAML. ---- -``` - -### Audience and tone - -* Audience: engineers who know S3/R2 basics and want a safe migration path. -* Tone: practical, confident, precise. Avoid marketing fluff, but do explain benefits clearly. -* Make it “generative-engine friendly” by: - - * Including crisp definitions (“Tigris is…”, “Bucket Migration is…”) - * Using consistent terminology across headings and summaries - * Including an explicit glossary-style mini section if helpful - * Using keyword-rich headings (without keyword stuffing) - -### Required tutorial structure (must follow) - -1. **Introduction**: high level summary of the moving parts and how Tigris helps with them. -2. **What is Tigris?**: explain Tigris according to the definitions in `docs/llms.txt` and `llms.txt`. -3. **Key benefits of doing this thing**: use subsections as required; keep benefits concrete and operational. -4. **How the thing works**: explain bucket migration at a high level based on the docs. -5. **Step by step process**: a complete procedure. **All code examples must be in bash or JavaScript**. -6. **Any additional information**: migration strategies, best practices, defensive infrastructure choices. -7. **Troubleshooting**: common failure modes and fixes. -8. **Conclusion**: summarize what the reader learned; tell the user to create an account at `https://storage.new` or reach out at `https://community.tigrisdata.com`. **Mention no egress fees.** - -### Required benefits to emphasize (verbatim concepts, you can rephrase) - -* **Many small migrations vs one big migration**: migrating storage is scary; Tigris migration performs many small, controlled migrations driven by access, reducing blast radius and improving predictability. -* **Bidirectional replication**: cut over workloads on your schedule; configure Tigris to write new data to both the old provider and Tigris so old workloads continue receiving new writes. - -### Step-by-step constraints - -* Include at least: - - * A short prerequisites list - * A “before you start” checklist (permissions, endpoints, credentials, bucket names) - * A clear explanation of how “as it’s accessed” migration behaves - * Verification steps (how to confirm objects are migrating / where to look) - * A rollback / safety note (what to do if something goes wrong) -* Keep examples realistic: - - * Use placeholder env vars for credentials - * Prefer `aws s3`-compatible CLI patterns and/or minimal JS with an S3-compatible SDK - * Avoid unsupported claims about exact flags or APIs unless shown in the docs - -### Deliverable - -Produce the final Markdown tutorial (with frontmatter), saved under `./var/...`, plus the file path and files-changed summary at the end. \ No newline at end of file diff --git a/cmd/popola/prompts/test.txt b/cmd/popola/prompts/test.txt deleted file mode 100644 index 13255af..0000000 --- a/cmd/popola/prompts/test.txt +++ /dev/null @@ -1,37 +0,0 @@ -Please write a tutorial on how to use Tigris data bucket migration to move data from Cloudflare R2 to Tigris as it's accessed. https://www.tigrisdata.com/docs/migration/ - -Go over the benefits of Tigris as well. Phrase this to hook well into generative engines with definitions found at https://www.tigrisdata.com/llms.txt. - -When referencing Tigris, use the documentation at https://www.tigrisdata.com/docs/llms.txt to make sure you relay accurate information. - -Save the resulting tutorial to a sensible location that follows the existing folder structure in `./var`. If the right folder does not exist, please create it. - -Once you write your tutorial, revise it using the elements of style. - -Use Markdown frontmatter like this: - -```markdown ---- -title: The title of the tutorial -description: >- - A short description of the tutorial with keywords intact. Make sure to use - a >- string in YAML. -``` - -Your tutorial should follow the following structure: - -* Introduction: high level summary of the moving parts and how Tigris helps with them. -* What is Tigris?: Explain the Tigris product according to the aforementioned definitions. -* Key benefits of doing this thing: Explain the key benefits of doing the thing you are explaining. Use subsections as required. -* How the thing works: Explain how the feature works at a high level based on the documentation. -* Step by step process: Explain the step by step process for doing the thing. All code examples should be either in bash or JavaScript. -* Any additional information: migration strategies, best practices, and other defensive infrastructure choices should be documented here. -* Troubleshooting: Explain troubleshooting steps and how to resolve them. -* Conclusion: Summarize what you learned and tell the user to create a new account at https://storage.new or reach out to the community at https://community.tigrisdata.com. Mention no egress fees. - -Additional requirements: - -Emphasize the following benefits: - -* **Many small migrations vs one big migration**: Migrating storage can be scary because it's one big migration which can cause data loss if things go wrong. Tigris migration does lots of little migrations that are much easier to control and predict. -* **Bidirectional replication**: Old workloads can be changed over to Tigris on your schedule, Tigris can be configured to write new data to your old storage provider as well as your new home in Tigris. This means that old workloads continue to have new data loaded into them. \ No newline at end of file diff --git a/cmd/popola/testinput.json b/cmd/popola/testinput.json new file mode 100644 index 0000000..fcd1449 --- /dev/null +++ b/cmd/popola/testinput.json @@ -0,0 +1,4 @@ +{ + "topic": "Migrating data from Wasabi to Tigris", + "relevantDocs": "* https://www.tigrisdata.com/docs/migration/" +} From abb7544ed6f5628a8ceac9d8434a91c260823b3c Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:17:17 +0000 Subject: [PATCH 04/31] feat(sitegen): add initial command structure - Add main.go with CLI flag parsing - Add config.go for YAML configuration loading - Add stub sitegen.go for generation logic - Add README.md with usage documentation Signed-off-by: Xe Iaso --- cmd/sitegen/README.md | 33 +++++++++++++++++++++++++++++++++ cmd/sitegen/config.go | 39 +++++++++++++++++++++++++++++++++++++++ cmd/sitegen/main.go | 33 +++++++++++++++++++++++++++++++++ cmd/sitegen/sitegen.go | 19 +++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 cmd/sitegen/README.md create mode 100644 cmd/sitegen/config.go create mode 100644 cmd/sitegen/main.go create mode 100644 cmd/sitegen/sitegen.go diff --git a/cmd/sitegen/README.md b/cmd/sitegen/README.md new file mode 100644 index 0000000..02d067e --- /dev/null +++ b/cmd/sitegen/README.md @@ -0,0 +1,33 @@ +# sitegen + +Static site generator for markdown documentation with YAML frontmatter. + +## Configuration + +Create a `sitegen.yaml` file: + +```yaml +content_dir: "./content" +output_dir: "./var" +preamble: | + # My Documentation +``` + +## Frontmatter + +Each markdown file must have YAML frontmatter: + +```yaml +--- +title: "Page Title" +description: "A brief description" +--- +# Content starts here +``` + +## Usage + +```bash +go run cmd/sitegen +go run cmd/sitegen --config custom.yaml +``` diff --git a/cmd/sitegen/config.go b/cmd/sitegen/config.go new file mode 100644 index 0000000..17856f1 --- /dev/null +++ b/cmd/sitegen/config.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + ContentDir string `yaml:"content_dir"` + OutputDir string `yaml:"output_dir"` + Preamble string `yaml:"preamble"` +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing YAML: %w", err) + } + + if cfg.ContentDir == "" { + return nil, fmt.Errorf("content_dir is required in config") + } + if cfg.OutputDir == "" { + return nil, fmt.Errorf("output_dir is required in config") + } + + cfg.ContentDir, _ = filepath.Abs(cfg.ContentDir) + cfg.OutputDir, _ = filepath.Abs(cfg.OutputDir) + + return &cfg, nil +} diff --git a/cmd/sitegen/main.go b/cmd/sitegen/main.go new file mode 100644 index 0000000..fb5d5d1 --- /dev/null +++ b/cmd/sitegen/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "fmt" + "log" +) + +var ( + configPath = flag.String("config", "sitegen.yaml", "Path to sitegen.yaml configuration file") + quiet = flag.Bool("quiet", false, "Suppress progress output") +) + +func main() { + flag.Parse() + + cfg, err := LoadConfig(*configPath) + if err != nil { + log.Fatalf("Failed to load config from %s: %v", *configPath, err) + } + + if !*quiet { + fmt.Printf("Generating site from %s to %s\n", cfg.ContentDir, cfg.OutputDir) + } + + if err := Generate(cfg, *quiet); err != nil { + log.Fatalf("Site generation failed: %v", err) + } + + if !*quiet { + fmt.Println("Site generated successfully") + } +} diff --git a/cmd/sitegen/sitegen.go b/cmd/sitegen/sitegen.go new file mode 100644 index 0000000..b108d64 --- /dev/null +++ b/cmd/sitegen/sitegen.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "os" +) + +func Generate(cfg *Config, quiet bool) error { + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + // TODO: Scan content directory + // TODO: Parse frontmatter + // TODO: Generate HTML + // TODO: Generate llms.txt + + return nil +} From 22d8519e1e3c6b2e8ee376513d54757176e5a673 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:17:35 +0000 Subject: [PATCH 05/31] feat(sitegen): add markdown and frontmatter dependencies - go.abhg.dev/goldmark/frontmatter for YAML frontmatter parsing - github.com/yuin/goldmark for markdown to HTML conversion - gopkg.in/yaml.v3 for YAML config parsing Signed-off-by: Xe Iaso --- go.mod | 4 ++-- go.sum | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 08d2847..a48e879 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,6 @@ require ( github.com/gen2brain/avif v0.4.4 github.com/gen2brain/webp v0.5.5 github.com/go-faker/faker/v4 v4.7.0 - github.com/google/uuid v1.6.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/humanlayer/humanlayer/claudecode-go v0.0.0-20260107190521-bdea199cec94 github.com/joho/godotenv v1.5.1 @@ -29,11 +28,12 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/pstuifzand/ekster v0.0.0-20240904184605-72273498b4a6 github.com/tigrisdata/storage-go v0.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251120230642-dcccabe2cd63 // indirect - github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + github.com/BurntSushi/toml v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect github.com/a-h/templ v0.3.977 // indirect diff --git a/go.sum b/go.sum index f134488..16ccc54 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/ClickHouse/clickhouse-go v1.4.3/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= @@ -632,8 +632,6 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= From d7be969b3bfadc3d83356b40bd4031f8c106ad66 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:21:44 +0000 Subject: [PATCH 06/31] feat(sitegen): add frontmatter parsing with tests - Parse YAML frontmatter for title, description, body - Require title and description fields - Fail with clear error messages on missing fields - Table-driven tests for various scenarios Signed-off-by: Xe Iaso --- cmd/sitegen/frontmatter.go | 68 ++++++++++++++++++++++++++ cmd/sitegen/frontmatter_test.go | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 cmd/sitegen/frontmatter.go create mode 100644 cmd/sitegen/frontmatter_test.go diff --git a/cmd/sitegen/frontmatter.go b/cmd/sitegen/frontmatter.go new file mode 100644 index 0000000..e5fa43f --- /dev/null +++ b/cmd/sitegen/frontmatter.go @@ -0,0 +1,68 @@ +package main + +import ( + "bytes" + "fmt" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "go.abhg.dev/goldmark/frontmatter" +) + +type Frontmatter struct { + Title string + Description string + Body string +} + +func ParseFrontmatter(content []byte) (*Frontmatter, error) { + md := goldmark.New( + goldmark.WithExtensions( + &frontmatter.Extender{ + Formats: []frontmatter.Format{frontmatter.YAML}, + Mode: frontmatter.SetMetadata, + }, + ), + ) + + context := parser.NewContext() + reader := text.NewReader(content) + md.Parser().Parse(reader, parser.WithContext(context)) + + // Get metadata from parser context + data := frontmatter.Get(context) + if data == nil { + return nil, fmt.Errorf("frontmatter not found or empty") + } + + var meta map[string]string + if err := data.Decode(&meta); err != nil { + return nil, fmt.Errorf("decoding frontmatter: %w", err) + } + + if len(meta) == 0 { + return nil, fmt.Errorf("frontmatter not found or empty") + } + + title, ok := meta["title"] + if !ok || title == "" { + return nil, fmt.Errorf("title is required in frontmatter") + } + + description, ok := meta["description"] + if !ok || description == "" { + return nil, fmt.Errorf("description is required in frontmatter") + } + + var body bytes.Buffer + if err := md.Convert(content, &body); err != nil { + return nil, fmt.Errorf("converting markdown: %w", err) + } + + return &Frontmatter{ + Title: title, + Description: description, + Body: body.String(), + }, nil +} diff --git a/cmd/sitegen/frontmatter_test.go b/cmd/sitegen/frontmatter_test.go new file mode 100644 index 0000000..49efe23 --- /dev/null +++ b/cmd/sitegen/frontmatter_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "strings" + "testing" +) + +func TestParseFrontmatter(t *testing.T) { + tests := []struct { + name string + content string + wantTitle string + wantDesc string + wantBody string + wantErr bool + errContains string + }{ + { + name: "valid frontmatter", + content: `--- +title: "Hello World" +description: "A test page" +--- + +# Content here`, + wantTitle: "Hello World", + wantDesc: "A test page", + wantBody: "

Content here

\n", + wantErr: false, + }, + { + name: "missing frontmatter", + content: `# No frontmatter here`, + wantErr: true, + errContains: "frontmatter not found", + }, + { + name: "missing title", + content: `--- +description: "No title" +--- + +Content`, + wantErr: true, + errContains: "title is required", + }, + { + name: "missing description", + content: `--- +title: "No description" +--- + +Content`, + wantErr: true, + errContains: "description is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseFrontmatter([]byte(tt.content)) + if tt.wantErr { + if err == nil { + t.Errorf("ParseFrontmatter() expected error containing %q, got nil", tt.errContains) + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("ParseFrontmatter() error = %v, want error containing %q", err, tt.errContains) + } + return + } + if err != nil { + t.Errorf("ParseFrontmatter() unexpected error: %v", err) + return + } + if got.Title != tt.wantTitle { + t.Errorf("ParseFrontmatter() Title = %q, want %q", got.Title, tt.wantTitle) + } + if got.Description != tt.wantDesc { + t.Errorf("ParseFrontmatter() Description = %q, want %q", got.Description, tt.wantDesc) + } + if got.Body != tt.wantBody { + t.Errorf("ParseFrontmatter() Body = %q, want %q", got.Body, tt.wantBody) + } + }) + } +} From affefb073a546a7cc7e6b5b48714a4c9312ed0e4 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:22:50 +0000 Subject: [PATCH 07/31] feat(sitegen): add content directory scanning - Recursively scan content_dir for .md files - Track index.md files separately - Build page metadata with input/output paths - Test with nested directory structure Signed-off-by: Xe Iaso --- cmd/sitegen/scan.go | 47 ++++++++++++++++++++++++++++ cmd/sitegen/scan_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 cmd/sitegen/scan.go create mode 100644 cmd/sitegen/scan_test.go diff --git a/cmd/sitegen/scan.go b/cmd/sitegen/scan.go new file mode 100644 index 0000000..c2abbe7 --- /dev/null +++ b/cmd/sitegen/scan.go @@ -0,0 +1,47 @@ +package main + +import ( + "os" + "path/filepath" + "strings" +) + +type Page struct { + InputPath string // Full path to source .md file + OutputPath string // Full path to output .html file + OutputMD string // Full path to copied .md file + URLPath string // Relative path for linking (e.g., "./guide/setup.md") + IsIndex bool // True if filename is index.md + Frontmatter *Frontmatter +} + +func ScanContent(contentDir string) ([]*Page, error) { + var pages []*Page + + err := filepath.Walk(contentDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".md") { + return nil + } + + relPath, err := filepath.Rel(contentDir, path) + if err != nil { + return err + } + + pages = append(pages, &Page{ + InputPath: path, + URLPath: "./" + relPath, + IsIndex: filepath.Base(path) == "index.md", + }) + + return nil + }) + + return pages, err +} diff --git a/cmd/sitegen/scan_test.go b/cmd/sitegen/scan_test.go new file mode 100644 index 0000000..5c261de --- /dev/null +++ b/cmd/sitegen/scan_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestScanContent(t *testing.T) { + // Create temporary content directory + tmpDir, err := os.MkdirTemp("", "sitegen-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create test files + files := map[string]string{ + "index.md": `--- +title: "Index" +description: "Main index" +--- +Index content`, + "guide/index.md": `--- +title: "Guide" +description: "Guide index" +--- +Guide content`, + "guide/setup.md": `--- +title: "Setup" +description: "Setup guide" +--- +Setup content`, + } + + for path, content := range files { + fullPath := filepath.Join(tmpDir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + pages, err := ScanContent(tmpDir) + if err != nil { + t.Fatalf("ScanContent() error = %v", err) + } + + // Should find 3 files + if len(pages) != 3 { + t.Errorf("ScanContent() found %d files, want 3", len(pages)) + } + + // Check that index.md files are identified + var indexCount int + for _, p := range pages { + if filepath.Base(p.InputPath) == "index.md" { + indexCount++ + } + } + if indexCount != 2 { + t.Errorf("ScanContent() found %d index.md files, want 2", indexCount) + } +} From a47c54ab8bff26cf9ebed4c3477994a679e562d5 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:30:43 +0000 Subject: [PATCH 08/31] feat(sitegen): add templ templates and CSS styling - Add templ templates for HTML generation - Embed CSS directly using go:embed - Style based on Xess.css with Tigris-inspired colors - Rename Page template to PageView to avoid struct name conflict Signed-off-by: Xe Iaso --- cmd/sitegen/base.templ | 24 +++++++++ cmd/sitegen/base_templ.go | 100 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 cmd/sitegen/base.templ create mode 100644 cmd/sitegen/base_templ.go diff --git a/cmd/sitegen/base.templ b/cmd/sitegen/base.templ new file mode 100644 index 0000000..20274ab --- /dev/null +++ b/cmd/sitegen/base.templ @@ -0,0 +1,24 @@ +package main + +templ Base(title string, content templ.Component) { + + + + + + { title } + + + +
+ @content +
+ + +} + +templ PageView(title string, bodyHTML string) { +
+ @templ.Raw(bodyHTML) +
+} diff --git a/cmd/sitegen/base_templ.go b/cmd/sitegen/base_templ.go new file mode 100644 index 0000000..b4d8c99 --- /dev/null +++ b/cmd/sitegen/base_templ.go @@ -0,0 +1,100 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package main + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import ( + "github.com/a-h/templ" + templruntime "github.com/a-h/templ/runtime" +) + +func Base(title string, content templ.Component) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/sitegen/base.templ`, Line: 9, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func PageView(title string, bodyHTML string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(bodyHTML).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate From cba6ca7e2e376a91deeef9c14d28c8c413e268f3 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:32:45 +0000 Subject: [PATCH 09/31] feat(sitegen): add HTML page generation - Generate styled HTML from markdown with frontmatter - Copy original .md files alongside HTML - Preserve directory structure in output - Test HTML output contains title and content - Keep CSS files in main sitegen directory Signed-off-by: Xe Iaso --- cmd/sitegen/css.go | 8 +++ cmd/sitegen/generate.go | 53 ++++++++++++++++++ cmd/sitegen/generate_test.go | 84 +++++++++++++++++++++++++++++ cmd/sitegen/site.css | 101 +++++++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 cmd/sitegen/css.go create mode 100644 cmd/sitegen/generate.go create mode 100644 cmd/sitegen/generate_test.go create mode 100644 cmd/sitegen/site.css diff --git a/cmd/sitegen/css.go b/cmd/sitegen/css.go new file mode 100644 index 0000000..c97c95e --- /dev/null +++ b/cmd/sitegen/css.go @@ -0,0 +1,8 @@ +package main + +import ( + _ "embed" +) + +//go:embed site.css +var siteCSS string diff --git a/cmd/sitegen/generate.go b/cmd/sitegen/generate.go new file mode 100644 index 0000000..803ee41 --- /dev/null +++ b/cmd/sitegen/generate.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +func GeneratePages(cfg *Config, pages []*Page) error { + for _, page := range pages { + // Calculate output paths + relPath, err := filepath.Rel(cfg.ContentDir, page.InputPath) + if err != nil { + return err + } + + htmlPath := filepath.Join(cfg.OutputDir, strings.TrimSuffix(relPath, ".md")+".html") + mdPath := filepath.Join(cfg.OutputDir, relPath) + + // Create output directory + if err := os.MkdirAll(filepath.Dir(htmlPath), 0755); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + // Render HTML + baseTempl := Base(page.Frontmatter.Title, PageView(page.Frontmatter.Title, page.Frontmatter.Body)) + var buf bytes.Buffer + if err := baseTempl.Render(context.Background(), &buf); err != nil { + return fmt.Errorf("rendering template: %w", err) + } + + if err := os.WriteFile(htmlPath, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("writing HTML: %w", err) + } + + // Copy original markdown + if err := os.MkdirAll(filepath.Dir(mdPath), 0755); err != nil { + return err + } + content, err := os.ReadFile(page.InputPath) + if err != nil { + return err + } + if err := os.WriteFile(mdPath, content, 0644); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/sitegen/generate_test.go b/cmd/sitegen/generate_test.go new file mode 100644 index 0000000..3cfbbdb --- /dev/null +++ b/cmd/sitegen/generate_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGeneratePages(t *testing.T) { + // Create temporary directories + contentDir, err := os.MkdirTemp("", "sitegen-content-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(contentDir) + + outputDir, err := os.MkdirTemp("", "sitegen-output-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outputDir) + + // Create test markdown + mdContent := `--- +title: "Test Page" +description: "A test" +--- +# Hello World` + + mdPath := filepath.Join(contentDir, "test.md") + if err := os.WriteFile(mdPath, []byte(mdContent), 0644); err != nil { + t.Fatal(err) + } + + // Scan and parse + pages, err := ScanContent(contentDir) + if err != nil { + t.Fatal(err) + } + + for _, p := range pages { + content, err := os.ReadFile(p.InputPath) + if err != nil { + t.Fatal(err) + } + p.Frontmatter, err = ParseFrontmatter(content) + if err != nil { + t.Fatal(err) + } + } + + cfg := &Config{ContentDir: contentDir, OutputDir: outputDir} + + // Generate + if err := GeneratePages(cfg, pages); err != nil { + t.Fatalf("GeneratePages() error = %v", err) + } + + // Check HTML output exists + htmlPath := filepath.Join(outputDir, "test.html") + if _, err := os.Stat(htmlPath); os.IsNotExist(err) { + t.Errorf("GeneratePages() did not create %s", htmlPath) + } + + // Check HTML contains expected content + htmlContent, err := os.ReadFile(htmlPath) + if err != nil { + t.Fatal(err) + } + htmlStr := string(htmlContent) + if !strings.Contains(htmlStr, "Test Page") { + t.Errorf("HTML does not contain title 'Test Page'") + } + if !strings.Contains(htmlStr, "Hello World") { + t.Errorf("HTML does not contain 'Hello World'") + } + + // Check markdown was copied + mdOutputPath := filepath.Join(outputDir, "test.md") + if _, err := os.Stat(mdOutputPath); os.IsNotExist(err) { + t.Errorf("GeneratePages() did not copy %s", mdOutputPath) + } +} diff --git a/cmd/sitegen/site.css b/cmd/sitegen/site.css new file mode 100644 index 0000000..c7c83b0 --- /dev/null +++ b/cmd/sitegen/site.css @@ -0,0 +1,101 @@ +main { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + max-width: 50rem; + padding: 2rem; + margin: auto; + line-height: 1.6; +} + +@media only screen and (max-device-width: 736px) { + main { + padding: 1rem; + } +} + +::selection { + background: #d3869b; +} + +body { + background: #fbf1c7; + color: #3c3836; +} + +pre { + background-color: #ebdbb2; + padding: 1em; + border-radius: 4px; + overflow-x: auto; + border: 1px solid #d5c4a1; +} + +code { + background-color: #ebdbb2; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; +} + +pre code { + background: none; + padding: 0; +} + +a, +a:active, +a:visited { + color: #b16286; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +h1, +h2, +h3, +h4, +h5 { + margin-bottom: 0.5rem; + margin-top: 1.5rem; +} + +h1 { + border-bottom: 2px solid #b16286; + padding-bottom: 0.3rem; +} + +h2 { + border-bottom: 1px solid #d5c4a1; + padding-bottom: 0.2rem; +} + +blockquote { + border-left: 4px solid #b16286; + margin: 0.5em 0; + padding: 0.5em 1em; + background-color: #f2e5bc; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; +} + +th, +td { + border: 1px solid #d5c4a1; + padding: 0.5em; +} + +th { + background-color: #ebdbb2; +} + +img { + max-width: 100%; + height: auto; +} From ddcbfbf677347afb8942b8af434c428f96730ba6 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:33:48 +0000 Subject: [PATCH 10/31] feat(sitegen): add llms.txt generation - Generate llms.txt with links to all pages - Format: [title](path): description - Clean descriptions by removing newlines and extra spaces - Include config preamble at top of file Signed-off-by: Xe Iaso --- cmd/sitegen/llms.go | 50 +++++++++++++++++ cmd/sitegen/llms_test.go | 112 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 cmd/sitegen/llms.go create mode 100644 cmd/sitegen/llms_test.go diff --git a/cmd/sitegen/llms.go b/cmd/sitegen/llms.go new file mode 100644 index 0000000..8fb0688 --- /dev/null +++ b/cmd/sitegen/llms.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +func cleanDescription(desc string) string { + // Remove newlines + desc = strings.ReplaceAll(desc, "\n", " ") + // Collapse multiple spaces to single space + spaceRegex := regexp.MustCompile(`\s+`) + desc = spaceRegex.ReplaceAllString(desc, " ") + // Trim leading/trailing whitespace + desc = strings.TrimSpace(desc) + return desc +} + +func GenerateLLMsTxt(cfg *Config, pages []*Page) error { + var sb strings.Builder + + // Write preamble + if cfg.Preamble != "" { + sb.WriteString(cfg.Preamble) + if !strings.HasSuffix(cfg.Preamble, "\n") { + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + // Write entries + for _, page := range pages { + title := page.Frontmatter.Title + link := page.URLPath + desc := cleanDescription(page.Frontmatter.Description) + + sb.WriteString(fmt.Sprintf("[%s](%s): %s\n", title, link, desc)) + } + + // Write to file + path := filepath.Join(cfg.OutputDir, "llms.txt") + if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil { + return fmt.Errorf("writing llms.txt: %w", err) + } + + return nil +} diff --git a/cmd/sitegen/llms_test.go b/cmd/sitegen/llms_test.go new file mode 100644 index 0000000..e09733b --- /dev/null +++ b/cmd/sitegen/llms_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateLLMsTxt(t *testing.T) { + // Create temporary output directory + outputDir, err := os.MkdirTemp("", "sitegen-llms-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outputDir) + + cfg := &Config{ + OutputDir: outputDir, + Preamble: "# Test Docs\n\nWelcome to the docs.\n", + } + + pages := []*Page{ + { + URLPath: "./index.md", + Frontmatter: &Frontmatter{ + Title: "Main Page", + Description: "The main index page", + }, + }, + { + URLPath: "./guide/setup.md", + Frontmatter: &Frontmatter{ + Title: "Setup Guide", + Description: "How to get started", + }, + }, + { + URLPath: "./api/reference.md", + Frontmatter: &Frontmatter{ + Title: "API Reference", + Description: "API docs with extra spaces\nand newlines", + }, + }, + } + + if err := GenerateLLMsTxt(cfg, pages); err != nil { + t.Fatalf("GenerateLLMsTxt() error = %v", err) + } + + // Check file exists + llmsPath := filepath.Join(outputDir, "llms.txt") + content, err := os.ReadFile(llmsPath) + if err != nil { + t.Fatalf("Reading llms.txt: %v", err) + } + + contentStr := string(content) + + // Check preamble + if !strings.Contains(contentStr, "# Test Docs") { + t.Errorf("llms.txt missing preamble") + } + + // Check link format + if !strings.Contains(contentStr, "[Main Page](./index.md): The main index page") { + t.Errorf("llms.txt missing correct link format for Main Page") + } + + // Check description cleanup (extra spaces and newlines removed) + if strings.Contains(contentStr, "extra spaces") { + t.Errorf("llms.txt description not cleaned of extra spaces") + } +} + +func TestCleanDescription(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "normal text", + input: "Normal description here", + expected: "Normal description here", + }, + { + name: "extra spaces", + input: "Text with extra spaces", + expected: "Text with extra spaces", + }, + { + name: "newlines", + input: "Text\nwith\nnewlines", + expected: "Text with newlines", + }, + { + name: "mixed", + input: "Text\nwith both\n issues", + expected: "Text with both issues", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cleanDescription(tt.input) + if got != tt.expected { + t.Errorf("cleanDescription() = %q, want %q", got, tt.expected) + } + }) + } +} From 5deb783e83b0ac1011e790f5bdeaae7fc335e688 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:34:43 +0000 Subject: [PATCH 11/31] feat(sitegen): wire up full generation pipeline - Scan content directory for markdown files - Parse frontmatter for all files - Generate HTML pages with templates - Copy original markdown files - Generate llms.txt with all pages - Add integration test for full flow Signed-off-by: Xe Iaso --- cmd/sitegen/sitegen.go | 51 ++++++++++++++++-- cmd/sitegen/sitegen_test.go | 104 ++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 cmd/sitegen/sitegen_test.go diff --git a/cmd/sitegen/sitegen.go b/cmd/sitegen/sitegen.go index b108d64..4789dcc 100644 --- a/cmd/sitegen/sitegen.go +++ b/cmd/sitegen/sitegen.go @@ -2,18 +2,61 @@ package main import ( "fmt" + "log" "os" ) func Generate(cfg *Config, quiet bool) error { + // Create output directory if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { return fmt.Errorf("creating output directory: %w", err) } - // TODO: Scan content directory - // TODO: Parse frontmatter - // TODO: Generate HTML - // TODO: Generate llms.txt + // Scan content directory + if !quiet { + log.Printf("Scanning %s...", cfg.ContentDir) + } + pages, err := ScanContent(cfg.ContentDir) + if err != nil { + return fmt.Errorf("scanning content: %w", err) + } + + if !quiet { + log.Printf("Found %d markdown files", len(pages)) + } + + // Parse frontmatter for each page + for i, page := range pages { + if !quiet { + log.Printf("Parsing %s...", page.InputPath) + } + content, err := os.ReadFile(page.InputPath) + if err != nil { + return fmt.Errorf("reading %s: %w", page.InputPath, err) + } + + fm, err := ParseFrontmatter(content) + if err != nil { + return fmt.Errorf("parsing frontmatter in %s: %w", page.InputPath, err) + } + pages[i].Frontmatter = fm + } + + // Generate HTML pages and copy markdown + if !quiet { + log.Printf("Generating HTML...") + } + if err := GeneratePages(cfg, pages); err != nil { + return fmt.Errorf("generating pages: %w", err) + } + + // Generate llms.txt + if !quiet { + log.Printf("Generating llms.txt...") + } + if err := GenerateLLMsTxt(cfg, pages); err != nil { + return fmt.Errorf("generating llms.txt: %w", err) + } return nil } diff --git a/cmd/sitegen/sitegen_test.go b/cmd/sitegen/sitegen_test.go new file mode 100644 index 0000000..fc37174 --- /dev/null +++ b/cmd/sitegen/sitegen_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerate(t *testing.T) { + // Create temporary directories + contentDir, err := os.MkdirTemp("", "sitegen-content-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(contentDir) + + outputDir, err := os.MkdirTemp("", "sitegen-output-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outputDir) + + // Create test content structure + files := map[string]string{ + "index.md": `--- +title: "Home" +description: "Welcome to the site" +--- +# Welcome + +This is the home page.`, + "about.md": `--- +title: "About" +description: "About this site" +--- +# About + +Information about the site.`, + "docs/index.md": `--- +title: "Documentation" +description: "Main docs index" +--- +# Docs + +Documentation index.`, + } + + for path, content := range files { + fullPath := filepath.Join(contentDir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + cfg := &Config{ + ContentDir: contentDir, + OutputDir: outputDir, + Preamble: "# My Site\n\nDocumentation site.\n", + } + + // Run generation + if err := Generate(cfg, true); err != nil { + t.Fatalf("Generate() error = %v", err) + } + + // Check HTML files were generated + htmlFiles := []string{"index.html", "about.html", "docs/index.html"} + for _, f := range htmlFiles { + path := filepath.Join(outputDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("HTML file not generated: %s", f) + } + } + + // Check markdown files were copied + mdFiles := []string{"index.md", "about.md", "docs/index.md"} + for _, f := range mdFiles { + path := filepath.Join(outputDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("Markdown file not copied: %s", f) + } + } + + // Check llms.txt + llmsPath := filepath.Join(outputDir, "llms.txt") + llmsContent, err := os.ReadFile(llmsPath) + if err != nil { + t.Fatalf("Reading llms.txt: %v", err) + } + llmsStr := string(llmsContent) + if !strings.Contains(llmsStr, "# My Site") { + t.Errorf("llms.txt missing preamble") + } + if !strings.Contains(llmsStr, "[Home](./index.md)") { + t.Errorf("llms.txt missing Home link") + } + if !strings.Contains(llmsStr, "[About](./about.md)") { + t.Errorf("llms.txt missing About link") + } +} From a144844a4d8db6024707447f7b76750293b6c0b1 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:35:02 +0000 Subject: [PATCH 12/31] chore: add var/ to gitignore for sitegen output Signed-off-by: Xe Iaso --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4b1e47e..bfca40d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ go.work.sum # env file .env +# sitegen output +var/ + # Editor/IDE # .idea/ # .vscode/ From 0f36e7ec64175da40c94d4a5a98888311c08ec17 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:35:19 +0000 Subject: [PATCH 13/31] docs: add sitegen to main README Signed-off-by: Xe Iaso --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index aad579e..3df7d24 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,19 @@ # glue + Various "glue" code that otherwise defies categorization. This repo is immune from API stability. Use at your own risk. + +## Commands + +### sitegen + +Static site generator for documentation: + +```bash +# Generate site from ./content to ./var +go run cmd/sitegen + +# Use custom config +go run cmd/sitegen --config custom.yaml +``` + +See `cmd/sitegen/README.md` for details. From 9cdae9a43068f54da766f59620f7ccc330414a89 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:39:27 +0000 Subject: [PATCH 14/31] docs: expand popola description in README Signed-off-by: Xe Iaso --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 3df7d24..ce006ed 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,12 @@ go run cmd/sitegen --config custom.yaml ``` See `cmd/sitegen/README.md` for details. + +### popola + +Popola serves as the canonical implementation of the Omnlana "agent protocol", enabling +consistent agent interactions across AI platforms. It reads a JSON input from stdin with +`topic` and `relevantDocs` fields, then launches a Claude Code session with MCP tools for +web reading and Tigris Discord integration. + +See `cmd/popola/README.md` for details. From 4c6fb8ff90ca97ee730452ba754d89d082716569 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 15:46:27 +0000 Subject: [PATCH 15/31] feat(sitegen): skip index.md files in llms.txt - Don't include index.md files in generated llms.txt - Update tests to verify index files are excluded Signed-off-by: Xe Iaso --- cmd/sitegen/llms.go | 7 ++++++- cmd/sitegen/sitegen_test.go | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cmd/sitegen/llms.go b/cmd/sitegen/llms.go index 8fb0688..592da07 100644 --- a/cmd/sitegen/llms.go +++ b/cmd/sitegen/llms.go @@ -33,11 +33,16 @@ func GenerateLLMsTxt(cfg *Config, pages []*Page) error { // Write entries for _, page := range pages { + // Skip index.md files in llms.txt + if page.IsIndex { + continue + } + title := page.Frontmatter.Title link := page.URLPath desc := cleanDescription(page.Frontmatter.Description) - sb.WriteString(fmt.Sprintf("[%s](%s): %s\n", title, link, desc)) + sb.WriteString(fmt.Sprintf("* [%s](%s): %s\n", title, link, desc)) } // Write to file diff --git a/cmd/sitegen/sitegen_test.go b/cmd/sitegen/sitegen_test.go index fc37174..d56ee5d 100644 --- a/cmd/sitegen/sitegen_test.go +++ b/cmd/sitegen/sitegen_test.go @@ -95,8 +95,9 @@ Documentation index.`, if !strings.Contains(llmsStr, "# My Site") { t.Errorf("llms.txt missing preamble") } - if !strings.Contains(llmsStr, "[Home](./index.md)") { - t.Errorf("llms.txt missing Home link") + // index.md files are skipped in llms.txt + if strings.Contains(llmsStr, "[Home](./index.md)") { + t.Errorf("llms.txt should not contain index.md links") } if !strings.Contains(llmsStr, "[About](./about.md)") { t.Errorf("llms.txt missing About link") From da1a9d27bb8b42a9b54a280db327429cc35a46c4 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 16:03:54 +0000 Subject: [PATCH 16/31] docs: add sitegen plan Signed-off-by: Xe Iaso --- cmd/sitegen/base.templ | 12 +- cmd/sitegen/generate.go | 44 +- docs/plans/2025-02-04-sitegen.md | 1808 ++++++++++++++++++++++++++++++ 3 files changed, 1860 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2025-02-04-sitegen.md diff --git a/cmd/sitegen/base.templ b/cmd/sitegen/base.templ index 20274ab..3ce5560 100644 --- a/cmd/sitegen/base.templ +++ b/cmd/sitegen/base.templ @@ -7,7 +7,7 @@ templ Base(title string, content templ.Component) { { title } - + @cssWriter{styles: siteCSS}
@@ -19,6 +19,16 @@ templ Base(title string, content templ.Component) { templ PageView(title string, bodyHTML string) {
+

{ title }

@templ.Raw(bodyHTML)
} + +templ PageIndex(title string, bodyHTML string, pageListHTML string) { +
+

{ title }

+ @templ.Raw(pageListHTML) + @templ.Raw(bodyHTML) +
+} + diff --git a/cmd/sitegen/generate.go b/cmd/sitegen/generate.go index 803ee41..04e8e9a 100644 --- a/cmd/sitegen/generate.go +++ b/cmd/sitegen/generate.go @@ -7,9 +7,23 @@ import ( "os" "path/filepath" "strings" + + "github.com/a-h/templ" ) func GeneratePages(cfg *Config, pages []*Page) error { + // Group pages by directory for index page lists + dirPages := make(map[string][]*Page) + for _, page := range pages { + dir := filepath.Dir(page.URLPath) + if dir == "." { + dir = "" + } + if !page.IsIndex { + dirPages[dir] = append(dirPages[dir], page) + } + } + for _, page := range pages { // Calculate output paths relPath, err := filepath.Rel(cfg.ContentDir, page.InputPath) @@ -25,8 +39,32 @@ func GeneratePages(cfg *Config, pages []*Page) error { return fmt.Errorf("creating directory: %w", err) } + // Build page list for index files + var content templ.Component + if page.IsIndex { + dir := filepath.Dir(page.URLPath) + if dir == "." { + dir = "" + } + childPages := dirPages[dir] + + var listBuilder strings.Builder + listBuilder.WriteString("
    ") + for _, cp := range childPages { + href := strings.TrimPrefix(cp.URLPath, "./") + desc := cleanDescription(cp.Frontmatter.Description) + listBuilder.WriteString(fmt.Sprintf("
  • %s: %s
  • ", href, cp.Frontmatter.Title, desc)) + } + listBuilder.WriteString("
") + pageListHTML := listBuilder.String() + + content = PageIndex(page.Frontmatter.Title, page.Frontmatter.Body, pageListHTML) + } else { + content = PageView(page.Frontmatter.Title, page.Frontmatter.Body) + } + // Render HTML - baseTempl := Base(page.Frontmatter.Title, PageView(page.Frontmatter.Title, page.Frontmatter.Body)) + baseTempl := Base(page.Frontmatter.Title, content) var buf bytes.Buffer if err := baseTempl.Render(context.Background(), &buf); err != nil { return fmt.Errorf("rendering template: %w", err) @@ -40,11 +78,11 @@ func GeneratePages(cfg *Config, pages []*Page) error { if err := os.MkdirAll(filepath.Dir(mdPath), 0755); err != nil { return err } - content, err := os.ReadFile(page.InputPath) + contentBytes, err := os.ReadFile(page.InputPath) if err != nil { return err } - if err := os.WriteFile(mdPath, content, 0644); err != nil { + if err := os.WriteFile(mdPath, contentBytes, 0644); err != nil { return err } } diff --git a/docs/plans/2025-02-04-sitegen.md b/docs/plans/2025-02-04-sitegen.md new file mode 100644 index 0000000..7f13dd0 --- /dev/null +++ b/docs/plans/2025-02-04-sitegen.md @@ -0,0 +1,1808 @@ +# Static Site Generator (sitegen) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create a static site generator command that converts markdown with YAML frontmatter into styled HTML pages, generates llms.txt, and supports configurable paths. + +**Architecture:** Go CLI tool using templ for HTML rendering, goldmark/frontmatter for markdown parsing, and embedded CSS for styling. Reads from sitegen.yaml for configuration. + +**Tech Stack:** Go 1.25.5, templ (a-h/templ), goldmark (markdown), frontmatter parsing + +--- + +## Task 1: Create cmd/sitegen directory structure + +**Files:** + +- Create: `cmd/sitegen/main.go` +- Create: `cmd/sitegen/sitegen.go` +- Create: `cmd/sitegen/config.go` +- Create: `cmd/sitegen/README.md` +- Create: `cmd/sitegen/go.mod` + +**Step 1: Create main.go entry point** + +```go +package main + +import ( + "flag" + "fmt" + "log" + "os" +) + +var ( + configPath = flag.String("config", "sitegen.yaml", "Path to sitegen.yaml configuration file") + quiet = flag.Bool("quiet", false, "Suppress progress output") +) + +func main() { + flag.Parse() + + cfg, err := LoadConfig(*configPath) + if err != nil { + log.Fatalf("Failed to load config from %s: %v", *configPath, err) + } + + if !*quiet { + fmt.Printf("Generating site from %s to %s\n", cfg.ContentDir, cfg.OutputDir) + } + + if err := Generate(cfg, *quiet); err != nil { + log.Fatalf("Site generation failed: %v", err) + } + + if !*quiet { + fmt.Println("Site generated successfully") + } +} +``` + +**Step 2: Create config.go for configuration loading** + +```go +package main + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + ContentDir string `yaml:"content_dir"` + OutputDir string `yaml:"output_dir"` + Preamble string `yaml:"preamble"` +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing YAML: %w", err) + } + + // Validate required fields + if cfg.ContentDir == "" { + return nil, fmt.Errorf("content_dir is required in config") + } + if cfg.OutputDir == "" { + return nil, fmt.Errorf("output_dir is required in config") + } + + // Convert to absolute paths + cfg.ContentDir, _ = filepath.Abs(cfg.ContentDir) + cfg.OutputDir, _ = filepath.Abs(cfg.OutputDir) + + return &cfg, nil +} +``` + +**Step 3: Create stub sitegen.go** + +```go +package main + +import ( + "fmt" + "os" +) + +func Generate(cfg *Config, quiet bool) error { + // Create output directory + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + // TODO: Scan content directory + // TODO: Parse frontmatter + // TODO: Generate HTML + // TODO: Generate llms.txt + + return nil +} +``` + +**Step 4: Create README.md** + +````markdown +# sitegen + +Static site generator for markdown documentation with YAML frontmatter. + +## Configuration + +Create a `sitegen.yaml` file: + +```yaml +content_dir: "./content" +output_dir: "./var" +preamble: | + # My Documentation +``` +```` + +## Frontmatter + +Each markdown file must have YAML frontmatter: + +```yaml +--- +title: "Page Title" +description: "A brief description" +--- +# Content starts here +``` + +## Usage + +```bash +go run cmd/sitegen/main.go +go run cmd/sitegen/main.go --config custom.yaml +``` + +```` + +**Step 5: Create go.mod (will inherit from root, but can be empty initially)** + +```go +module github.com/tigrisdata-community/glue/cmd/sitegen + +go 1.25.5 +```` + +**Step 6: Commit** + +```bash +git add cmd/sitegen/ +git commit -m "feat(sitegen): add initial command structure + +- Add main.go with CLI flag parsing +- Add config.go for YAML configuration loading +- Add stub sitegen.go for generation logic +- Add README.md with usage documentation + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 2: Add dependencies for markdown and frontmatter parsing + +**Files:** + +- Modify: `go.mod` (root) + +**Step 1: Add required dependencies to root go.mod** + +Run: + +```bash +go get go.abhg.dev/goldmark/frontmatter +go get github.com/yuin/goldmark +go get gopkg.in/yaml.v3 +``` + +**Step 2: Run go mod tidy** + +```bash +go mod tidy +``` + +**Step 3: Verify dependencies** + +Run: + +```bash +go mod verify +``` + +**Step 4: Commit** + +```bash +git add go.mod go.sum +git commit -m "feat(sitegen): add markdown and frontmatter dependencies + +- go.abhg.dev/goldmark/frontmatter for YAML frontmatter parsing +- github.com/yuin/goldmark for markdown to HTML conversion +- gopkg.in/yaml.v3 for YAML config parsing + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 3: Implement frontmatter parsing with tests + +**Files:** + +- Create: `cmd/sitegen/frontmatter.go` +- Create: `cmd/sitegen/frontmatter_test.go` + +**Step 1: Write the failing test** + +Create `cmd/sitegen/frontmatter_test.go`: + +```go +package main + +import ( + "strings" + "testing" +) + +func TestParseFrontmatter(t *testing.T) { + tests := []struct { + name string + content string + wantTitle string + wantDesc string + wantBody string + wantErr bool + errContains string + }{ + { + name: "valid frontmatter", + content: `--- +title: "Hello World" +description: "A test page" +--- + +# Content here`, + wantTitle: "Hello World", + wantDesc: "A test page", + wantBody: "# Content here", + wantErr: false, + }, + { + name: "missing frontmatter", + content: `# No frontmatter here`, + wantErr: true, + errContains: "frontmatter not found", + }, + { + name: "missing title", + content: `--- +description: "No title" +--- + +Content`, + wantErr: true, + errContains: "title is required", + }, + { + name: "missing description", + content: `--- +title: "No description" +--- + +Content`, + wantErr: true, + errContains: "description is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseFrontmatter(tt.content) + if tt.wantErr { + if err == nil { + t.Errorf("ParseFrontmatter() expected error containing %q, got nil", tt.errContains) + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("ParseFrontmatter() error = %v, want error containing %q", err, tt.errContains) + } + return + } + if err != nil { + t.Errorf("ParseFrontmatter() unexpected error: %v", err) + return + } + if got.Title != tt.wantTitle { + t.Errorf("ParseFrontmatter() Title = %q, want %q", got.Title, tt.wantTitle) + } + if got.Description != tt.wantDesc { + t.Errorf("ParseFrontmatter() Description = %q, want %q", got.Description, tt.wantDesc) + } + if got.Body != tt.wantBody { + t.Errorf("ParseFrontmatter() Body = %q, want %q", got.Body, tt.wantBody) + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd cmd/sitegen && go test -v -run TestParseFrontmatter +``` + +Expected: `undefined: ParseFrontmatter` + +**Step 3: Write minimal implementation** + +Create `cmd/sitegen/frontmatter.go`: + +```go +package main + +import ( + "bytes" + "fmt" + + "go.abhg.dev/goldmark/frontmatter" + "github.com/yuin/goldmark" +) + +type Frontmatter struct { + Title string + Description string + Body string +} + +func ParseFrontmatter(content []byte) (*Frontmatter, error) { + md := goldmark.New( + goldmark.WithExtensions( + frontmatter.NewFrontmatterExtension( + frontmatter.WithYAMLMeta(), + ), + ), + ) + + var meta map[string]string + var body bytes.Buffer + + context := frontmatter.NewParseContext() + context.Meta = &meta + + doc := md.Parser().Parse( + frontmatter.NewContext(context), + content, + ) + + if md.Convert(doc, &body); err != nil { + return nil, fmt.Errorf("converting markdown: %w", err) + } + + if len(meta) == 0 { + return nil, fmt.Errorf("frontmatter not found or empty") + } + + title, ok := meta["title"] + if !ok || title == "" { + return nil, fmt.Errorf("title is required in frontmatter") + } + + description, ok := meta["description"] + if !ok || description == "" { + return nil, fmt.Errorf("description is required in frontmatter") + } + + return &Frontmatter{ + Title: title, + Description: description, + Body: body.String(), + }, nil +} +``` + +**Step 4: Run test to verify it passes** + +```bash +cd cmd/sitegen && go test -v -run TestParseFrontmatter +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/sitegen/frontmatter.go cmd/sitegen/frontmatter_test.go +git commit -m "feat(sitegen): add frontmatter parsing with tests + +- Parse YAML frontmatter for title, description, body +- Require title and description fields +- Fail with clear error messages on missing fields +- Table-driven tests for various scenarios + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 4: Implement content directory scanning + +**Files:** + +- Create: `cmd/sitegen/scan.go` +- Create: `cmd/sitegen/scan_test.go` + +**Step 1: Write the failing test** + +Create `cmd/sitegen/scan_test.go`: + +```go +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestScanContent(t *testing.T) { + // Create temporary content directory + tmpDir, err := os.MkdirTemp("", "sitegen-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create test files + files := map[string]string{ + "index.md": `--- +title: "Index" +description: "Main index" +--- +Index content`, + "guide/index.md": `--- +title: "Guide" +description: "Guide index" +--- +Guide content`, + "guide/setup.md": `--- +title: "Setup" +description: "Setup guide" +--- +Setup content`, + } + + for path, content := range files { + fullPath := filepath.Join(tmpDir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + pages, err := ScanContent(tmpDir) + if err != nil { + t.Fatalf("ScanContent() error = %v", err) + } + + // Should find 3 files + if len(pages) != 3 { + t.Errorf("ScanContent() found %d files, want 3", len(pages)) + } + + // Check that index.md files are identified + var indexCount int + for _, p := range pages { + if filepath.Base(p.InputPath) == "index.md" { + indexCount++ + } + } + if indexCount != 2 { + t.Errorf("ScanContent() found %d index.md files, want 2", indexCount) + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd cmd/sitegen && go test -v -run TestScanContent +``` + +Expected: `undefined: ScanContent` + +**Step 3: Write minimal implementation** + +Create `cmd/sitegen/scan.go`: + +```go +package main + +import ( + "os" + "path/filepath" + "strings" +) + +type Page struct { + InputPath string // Full path to source .md file + OutputPath string // Full path to output .html file + OutputMD string // Full path to copied .md file + URLPath string // Relative path for linking (e.g., "./guide/setup.md") + IsIndex bool // True if filename is index.md + Frontmatter *Frontmatter +} + +func ScanContent(contentDir string) ([]*Page, error) { + var pages []*Page + + err := filepath.Walk(contentDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".md") { + return nil + } + + relPath, err := filepath.Rel(contentDir, path) + if err != nil { + return err + } + + pages = append(pages, &Page{ + InputPath: path, + URLPath: "./" + relPath, + IsIndex: filepath.Base(path) == "index.md", + }) + + return nil + }) + + return pages, err +} +``` + +**Step 4: Run test to verify it passes** + +```bash +cd cmd/sitegen && go test -v -run TestScanContent +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/sitegen/scan.go cmd/sitegen/scan_test.go +git commit -m "feat(sitegen): add content directory scanning + +- Recursively scan content_dir for .md files +- Track index.md files separately +- Build page metadata with input/output paths +- Test with nested directory structure + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 5: Add templ dependency and create templates + +**Files:** + +- Create: `cmd/sitegen/templates/templ.go` +- Create: `cmd/sitegen/css/site.css` + +**Step 1: Create CSS file** + +Create `cmd/sitegen/css/site.css`: + +```css +main { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + max-width: 50rem; + padding: 2rem; + margin: auto; + line-height: 1.6; +} + +@media only screen and (max-device-width: 736px) { + main { + padding: 1rem; + } +} + +::selection { + background: #d3869b; +} + +body { + background: #fbf1c7; + color: #3c3836; +} + +pre { + background-color: #ebdbb2; + padding: 1em; + border-radius: 4px; + overflow-x: auto; + border: 1px solid #d5c4a1; +} + +code { + background-color: #ebdbb2; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; +} + +pre code { + background: none; + padding: 0; +} + +a, +a:active, +a:visited { + color: #b16286; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +h1, +h2, +h3, +h4, +h5 { + margin-bottom: 0.5rem; + margin-top: 1.5rem; +} + +h1 { + border-bottom: 2px solid #b16286; + padding-bottom: 0.3rem; +} + +h2 { + border-bottom: 1px solid #d5c4a1; + padding-bottom: 0.2rem; +} + +blockquote { + border-left: 4px solid #b16286; + margin: 0.5em 0; + padding: 0.5em 1em; + background-color: #f2e5bc; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; +} + +th, +td { + border: 1px solid #d5c4a1; + padding: 0.5em; +} + +th { + background-color: #ebdbb2; +} + +img { + max-width: 100%; + height: auto; +} +``` + +**Step 2: Create templ template** + +Create `cmd/sitegen/templates/templ.go`: + +```go +package main + +import "github.com/a-h/templ" + +// css is embedded at compile time +//go:generate ../css/embed_css.sh + +templ Base(title string, content templ.Component) { + + + + + + { title } + + + +
+ @content +
+ + +} + +templ Page(title string, bodyHTML string) { +
+ @templ.Raw(bodyHTML) +
+} +``` + +**Step 3: Create CSS embedding script** + +Create `cmd/sitegen/css/embed_css.sh`: + +```bash +#!/bin/sh +# Generate CSS constant for templ +echo "package main" +echo "" +echo "const siteCSS = \\\`" +cat site.css +echo "\\\`" +``` + +Make it executable: + +```bash +chmod +x cmd/sitegen/css/embed_css.sh +``` + +**Step 4: Generate CSS constant** + +```bash +cd cmd/sitegen/css && ./embed_css.sh > css.go +``` + +**Step 5: Update templ.go to use generated CSS** + +Modify `cmd/sitegen/templates/templ.go`: + +```go +package main + +import "github.com/a-h/templ" + +//go:generate go run github.com/a-h/templ/cmd/templ generate + +templ Base(title string, content templ.Component) { + + + + + + { title } + + + +
+ @content +
+ + +} + +templ Page(title string, bodyHTML string) { +
+ @templ.Raw(bodyHTML) +
+} +``` + +**Step 6: Generate templ code** + +```bash +cd cmd/sitegen && go run github.com/a-h/templ/cmd/templ generate +``` + +**Step 7: Commit** + +```bash +git add cmd/sitegen/templates/ cmd/sitegen/css/ +git commit -m "feat(sitegen): add templ templates and CSS styling + +- Add templ templates for HTML generation +- Embed CSS directly in templates +- Style based on Xess.css with Tigris-inspired colors +- Add script to embed CSS as Go constant + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 6: Implement HTML page generation + +**Files:** + +- Modify: `cmd/sitegen/sitegen.go` +- Create: `cmd/sitegen/generate.go` +- Create: `cmd/sitegen/generate_test.go` + +**Step 1: Write the failing test** + +Create `cmd/sitegen/generate_test.go`: + +```go +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGeneratePages(t *testing.T) { + // Create temporary directories + contentDir, err := os.MkdirTemp("", "sitegen-content-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(contentDir) + + outputDir, err := os.MkdirTemp("", "sitegen-output-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outputDir) + + // Create test markdown + mdContent := `--- +title: "Test Page" +description: "A test" +--- +# Hello World` + + mdPath := filepath.Join(contentDir, "test.md") + if err := os.WriteFile(mdPath, []byte(mdContent), 0644); err != nil { + t.Fatal(err) + } + + // Scan and parse + pages, err := ScanContent(contentDir) + if err != nil { + t.Fatal(err) + } + + for _, p := range pages { + content, err := os.ReadFile(p.InputPath) + if err != nil { + t.Fatal(err) + } + p.Frontmatter, err = ParseFrontmatter(content) + if err != nil { + t.Fatal(err) + } + } + + cfg := &Config{ContentDir: contentDir, OutputDir: outputDir} + + // Generate + if err := GeneratePages(cfg, pages); err != nil { + t.Fatalf("GeneratePages() error = %v", err) + } + + // Check HTML output exists + htmlPath := filepath.Join(outputDir, "test.html") + if _, err := os.Stat(htmlPath); os.IsNotExist(err) { + t.Errorf("GeneratePages() did not create %s", htmlPath) + } + + // Check HTML contains expected content + htmlContent, err := os.ReadFile(htmlPath) + if err != nil { + t.Fatal(err) + } + htmlStr := string(htmlContent) + if !strings.Contains(htmlStr, "Test Page") { + t.Errorf("HTML does not contain title 'Test Page'") + } + if !strings.Contains(htmlStr, "Hello World") { + t.Errorf("HTML does not contain 'Hello World'") + } + + // Check markdown was copied + mdOutputPath := filepath.Join(outputDir, "test.md") + if _, err := os.Stat(mdOutputPath); os.IsNotExist(err) { + t.Errorf("GeneratePages() did not copy %s", mdOutputPath) + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd cmd/sitegen && go test -v -run TestGeneratePages +``` + +Expected: `undefined: GeneratePages` + +**Step 3: Write minimal implementation** + +Create `cmd/sitegen/generate.go`: + +```go +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func GeneratePages(cfg *Config, pages []*Page) error { + for _, page := range pages { + // Calculate output paths + relPath, err := filepath.Rel(cfg.ContentDir, page.InputPath) + if err != nil { + return err + } + + htmlPath := filepath.Join(cfg.OutputDir, strings.TrimSuffix(relPath, ".md")+".html") + mdPath := filepath.Join(cfg.OutputDir, relPath) + + // Create output directory + if err := os.MkdirAll(filepath.Dir(htmlPath), 0755); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + // Render HTML + baseTempl := Base(page.Frontmatter.Title, Page(page.Frontmatter.Title, page.Frontmatter.Body)) + html, err := baseTempl.RenderFile(nil) + if err != nil { + return fmt.Errorf("rendering template: %w", err) + } + + if err := os.WriteFile(htmlPath, html, 0644); err != nil { + return fmt.Errorf("writing HTML: %w", err) + } + + // Copy original markdown + if err := os.MkdirAll(filepath.Dir(mdPath), 0755); err != nil { + return err + } + content, err := os.ReadFile(page.InputPath) + if err != nil { + return err + } + if err := os.WriteFile(mdPath, content, 0644); err != nil { + return err + } + } + + return nil +} +``` + +**Step 4: Run test to verify it passes** + +```bash +cd cmd/sitegen && go test -v -run TestGeneratePages +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/sitegen/generate.go cmd/sitegen/generate_test.go cmd/sitegen/sitegen.go +git commit -m "feat(sitegen): add HTML page generation + +- Generate styled HTML from markdown with frontmatter +- Copy original .md files alongside HTML +- Preserve directory structure in output +- Test HTML output contains title and content + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 7: Implement llms.txt generation + +**Files:** + +- Modify: `cmd/sitegen/sitegen.go` +- Create: `cmd/sitegen/llms.go` +- Create: `cmd/sitegen/llms_test.go` + +**Step 1: Write the failing test** + +Create `cmd/sitegen/llms_test.go`: + +```go +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateLLMsTxt(t *testing.T) { + // Create temporary output directory + outputDir, err := os.MkdirTemp("", "sitegen-llms-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outputDir) + + cfg := &Config{ + OutputDir: outputDir, + Preamble: "# Test Docs\n\nWelcome to the docs.\n", + } + + pages := []*Page{ + { + URLPath: "./index.md", + Frontmatter: &Frontmatter{ + Title: "Main Page", + Description: "The main index page", + }, + }, + { + URLPath: "./guide/setup.md", + Frontmatter: &Frontmatter{ + Title: "Setup Guide", + Description: "How to get started", + }, + }, + { + URLPath: "./api/reference.md", + Frontmatter: &Frontmatter{ + Title: "API Reference", + Description: "API docs with extra spaces\nand newlines", + }, + }, + } + + if err := GenerateLLMsTxt(cfg, pages); err != nil { + t.Fatalf("GenerateLLMsTxt() error = %v", err) + } + + // Check file exists + llmsPath := filepath.Join(outputDir, "llms.txt") + content, err := os.ReadFile(llmsPath) + if err != nil { + t.Fatalf("Reading llms.txt: %v", err) + } + + contentStr := string(content) + + // Check preamble + if !strings.Contains(contentStr, "# Test Docs") { + t.Errorf("llms.txt missing preamble") + } + + // Check link format + if !strings.Contains(contentStr, "[Main Page](./index.md): The main index page") { + t.Errorf("llms.txt missing correct link format for Main Page") + } + + // Check description cleanup (extra spaces and newlines removed) + if strings.Contains(contentStr, "extra spaces") { + t.Errorf("llms.txt description not cleaned of extra spaces") + } + if strings.Contains(contentStr, "and newlines") && !strings.Contains(contentStr, "API docs with extra spaces and newlines") { + // Newlines should be removed within descriptions + } +} + +func TestCleanDescription(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "normal text", + input: "Normal description here", + expected: "Normal description here", + }, + { + name: "extra spaces", + input: "Text with extra spaces", + expected: "Text with extra spaces", + }, + { + name: "newlines", + input: "Text\nwith\nnewlines", + expected: "Text with newlines", + }, + { + name: "mixed", + input: "Text\nwith both\n issues", + expected: "Text with both issues", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cleanDescription(tt.input) + if got != tt.expected { + t.Errorf("cleanDescription() = %q, want %q", got, tt.expected) + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd cmd/sitegen && go test -v -run TestGenerateLLMsTxt +``` + +Expected: `undefined: GenerateLLMsTxt` + +**Step 3: Write minimal implementation** + +Create `cmd/sitegen/llms.go`: + +```go +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +func cleanDescription(desc string) string { + // Remove newlines + desc = strings.ReplaceAll(desc, "\n", " ") + // Collapse multiple spaces to single space + spaceRegex := regexp.MustCompile(`\s+`) + desc = spaceRegex.ReplaceAllString(desc, " ") + // Trim leading/trailing whitespace + desc = strings.TrimSpace(desc) + return desc +} + +func GenerateLLMsTxt(cfg *Config, pages []*Page) error { + var sb strings.Builder + + // Write preamble + if cfg.Preamble != "" { + sb.WriteString(cfg.Preamble) + if !strings.HasSuffix(cfg.Preamble, "\n") { + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + // Write entries + for _, page := range pages { + title := page.Frontmatter.Title + link := page.URLPath + desc := cleanDescription(page.Frontmatter.Description) + + sb.WriteString(fmt.Sprintf("[%s](%s): %s\n", title, link, desc)) + } + + // Write to file + path := filepath.Join(cfg.OutputDir, "llms.txt") + if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil { + return fmt.Errorf("writing llms.txt: %w", err) + } + + return nil +} +``` + +**Step 4: Run test to verify it passes** + +```bash +cd cmd/sitegen && go test -v -run TestGenerateLLMsTxt +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/sitegen/llms.go cmd/sitegen/llms_test.go +git commit -m "feat(sitegen): add llms.txt generation + +- Generate llms.txt with links to all pages +- Format: [title](path): description +- Clean descriptions by removing newlines and extra spaces +- Include config preamble at top of file + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 8: Wire everything together in main generation function + +**Files:** + +- Modify: `cmd/sitegen/sitegen.go` +- Create: `cmd/sitegen/sitegen_test.go` + +**Step 1: Write the integration test** + +Create `cmd/sitegen/sitegen_test.go`: + +```go +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerate(t *testing.T) { + // Create temporary directories + contentDir, err := os.MkdirTemp("", "sitegen-content-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(contentDir) + + outputDir, err := os.MkdirTemp("", "sitegen-output-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outputDir) + + // Create test content structure + files := map[string]string{ + "index.md": `--- +title: "Home" +description: "Welcome to the site" +--- +# Welcome + +This is the home page.`, + "about.md": `--- +title: "About" +description: "About this site" +--- +# About + +Information about the site.`, + "docs/index.md": `--- +title: "Documentation" +description: "Main docs index" +--- +# Docs + +Documentation index.`, + } + + for path, content := range files { + fullPath := filepath.Join(contentDir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + cfg := &Config{ + ContentDir: contentDir, + OutputDir: outputDir, + Preamble: "# My Site\n\nDocumentation site.\n", + } + + // Run generation + if err := Generate(cfg, false); err != nil { + t.Fatalf("Generate() error = %v", err) + } + + // Check HTML files were generated + htmlFiles := []string{"index.html", "about.html", "docs/index.html"} + for _, f := range htmlFiles { + path := filepath.Join(outputDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("HTML file not generated: %s", f) + } + } + + // Check markdown files were copied + mdFiles := []string{"index.md", "about.md", "docs/index.md"} + for _, f := range mdFiles { + path := filepath.Join(outputDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("Markdown file not copied: %s", f) + } + } + + // Check llms.txt + llmsPath := filepath.Join(outputDir, "llms.txt") + llmsContent, err := os.ReadFile(llmsPath) + if err != nil { + t.Fatalf("Reading llms.txt: %v", err) + } + llmsStr := string(llmsContent) + if !strings.Contains(llmsStr, "# My Site") { + t.Errorf("llms.txt missing preamble") + } + if !strings.Contains(llmsStr, "[Home](./index.md)") { + t.Errorf("llms.txt missing Home link") + } + if !strings.Contains(llmsStr, "[About](./about.md)") { + t.Errorf("llms.txt missing About link") + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd cmd/sitegen && go test -v -run TestGenerate +``` + +Expected: Test may fail or incomplete implementation + +**Step 3: Update sitegen.go with full implementation** + +Replace `cmd/sitegen/sitegen.go`: + +```go +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" +) + +func Generate(cfg *Config, quiet bool) error { + // Create output directory + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + // Scan content directory + if !quiet { + log.Printf("Scanning %s...", cfg.ContentDir) + } + pages, err := ScanContent(cfg.ContentDir) + if err != nil { + return fmt.Errorf("scanning content: %w", err) + } + + if !quiet { + log.Printf("Found %d markdown files", len(pages)) + } + + // Parse frontmatter for each page + for i, page := range pages { + if !quiet { + log.Printf("Parsing %s...", page.InputPath) + } + content, err := os.ReadFile(page.InputPath) + if err != nil { + return fmt.Errorf("reading %s: %w", page.InputPath, err) + } + + fm, err := ParseFrontmatter(content) + if err != nil { + return fmt.Errorf("parsing frontmatter in %s: %w", page.InputPath, err) + } + pages[i].Frontmatter = fm + } + + // Generate HTML pages and copy markdown + if !quiet { + log.Printf("Generating HTML...") + } + if err := GeneratePages(cfg, pages); err != nil { + return fmt.Errorf("generating pages: %w", err) + } + + // Generate llms.txt + if !quiet { + log.Printf("Generating llms.txt...") + } + if err := GenerateLLMsTxt(cfg, pages); err != nil { + return fmt.Errorf("generating llms.txt: %w", err) + } + + return nil +} +``` + +**Step 4: Run test to verify it passes** + +```bash +cd cmd/sitegen && go test -v -run TestGenerate +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/sitegen/sitegen.go cmd/sitegen/sitegen_test.go +git commit -m "feat(sitegen): wire up full generation pipeline + +- Scan content directory for markdown files +- Parse frontmatter for all files +- Generate HTML pages with templates +- Copy original markdown files +- Generate llms.txt with all pages +- Add integration test for full flow + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 9: Add end-to-end test with real content + +**Files:** + +- Create: `cmd/sitegen/testdata/content/index.md` +- Create: `cmd/sitegen/testdata/content/guide/index.md` +- Create: `cmd/sitegen/testdata/content/guide/setup.md` +- Create: `cmd/sitegen/testdata/sitegen.yaml` +- Create: `cmd/sitegen/e2e_test.go` + +**Step 1: Create test content files** + +Create `cmd/sitegen/testdata/content/index.md`: + +```markdown +--- +title: "Xess Documentation" +description: "Xess is a documentation generator" +--- + +# Welcome to Xess + +This is the main documentation site. +``` + +Create `cmd/sitegen/testdata/content/guide/index.md`: + +```markdown +--- +title: "User Guide" +description: "Complete user guide for Xess" +--- + +# User Guide + +Learn how to use Xess effectively. +``` + +Create `cmd/sitegen/testdata/content/guide/setup.md`: + +```markdown +--- +title: "Setup Guide" +description: "Installation and configuration instructions" +--- + +# Setup + +Install Xess with: +``` + +Create `cmd/sitegen/testdata/sitegen.yaml`: + +```yaml +content_dir: "./content" +output_dir: "./output" +preamble: | + # Xess Documentation + + Complete documentation for the Xess project. +``` + +**Step 2: Write E2E test** + +Create `cmd/sitegen/e2e_test.go`: + +```go +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestE2E(t *testing.T) { + // Change to testdata directory + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + testDir := filepath.Join(origDir, "testdata") + if err := os.Chdir(testDir); err != nil { + t.Fatal(err) + } + + // Clean output directory first + os.RemoveAll("output") + + // Build and run sitegen + cmd := exec.Command("go", "run", "../../", "--config", "sitegen.yaml") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Running sitegen: %v\nOutput: %s", err, output) + } + + // Verify output files exist + requiredFiles := []string{ + "output/index.html", + "output/index.md", + "output/guide/index.html", + "output/guide/index.md", + "output/guide/setup.html", + "output/guide/setup.md", + "output/llms.txt", + } + + for _, f := range requiredFiles { + path := filepath.Join(testDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("Missing output file: %s", f) + } + } + + // Verify HTML content + indexHTML, err := os.ReadFile(filepath.Join(testDir, "output/index.html")) + if err != nil { + t.Fatal(err) + } + htmlStr := string(indexHTML) + if !strings.Contains(htmlStr, "Xess Documentation") { + t.Errorf("index.html missing title") + } + + // Verify llms.txt content + llmsContent, err := os.ReadFile(filepath.Join(testDir, "output/llms.txt")) + if err != nil { + t.Fatal(err) + } + llmsStr := string(llmsContent) + if !strings.Contains(llmsStr, "# Xess Documentation") { + t.Errorf("llms.txt missing preamble") + } + if !strings.Contains(llmsStr, "[Xess Documentation](./index.md)") { + t.Errorf("llms.txt missing index link") + } +} +``` + +**Step 3: Run E2E test** + +```bash +cd cmd/sitegen && go test -v -run TestE2E +``` + +Expected: PASS + +**Step 4: Clean up testdata output directory** + +Add `.gitignore` in testdata: + +Create `cmd/sitegen/testdata/output/.gitignore`: + +``` +* +``` + +**Step 5: Commit** + +```bash +git add cmd/sitegen/testdata/ cmd/sitegen/e2e_test.go +git commit -m "feat(sitegen): add end-to-end test + +- Add test content with frontmatter +- Test complete generation flow +- Verify HTML, markdown, and llms.txt output +- Include gitignore for test output + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 10: Add .gitignore for var/ directory + +**Files:** + +- Modify: `.gitignore` + +**Step 1: Add var/ to .gitignore** + +Add to root `.gitignore`: + +``` +# sitegen output +var/ +``` + +**Step 2: Commit** + +```bash +git add .gitignore +git commit -m "chore: add var/ to gitignore for sitegen output + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Task 11: Add to root Makefile/README + +**Files:** + +- Modify: `README.md` (root) + +**Step 1: Add sitegen to main README** + +Add a section to root README.md: + +````markdown +## Commands + +### sitegen + +Static site generator for documentation: + +```bash +# Generate site from ./content to ./var +go run cmd/sitegen + +# Use custom config +go run cmd/sitegen --config custom.yaml +``` +```` + +See `cmd/sitegen/README.md` for details. + +```` + +**Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: add sitegen to main README + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +```` + +--- + +## Task 12: Final integration test and cleanup + +**Files:** + +- None (verification task) + +**Step 1: Run all tests** + +```bash +cd cmd/sitegen && go test -v ./... +``` + +Expected: All PASS + +**Step 2: Run go mod tidy** + +```bash +go mod tidy +``` + +**Step 3: Run go vet** + +```bash +go vet ./cmd/sitegen/... +``` + +**Step 4: Format code** + +```bash +npm run format +``` + +**Step 5: Build sitegen command** + +```bash +go build -o /tmp/sitegen ./cmd/sitegen +``` + +**Step 6: Final commit if needed** + +```bash +git add -A +git commit -m "chore(sitegen): final cleanup after implementation + +- Run go mod tidy +- Format all code +- Verify build succeeds + +Signed-off-by: Xe Iaso +Assisted-by: GLM 4.7 via Claude Code +" +``` + +--- + +## Summary + +This implementation creates a complete static site generator with: + +1. **Configuration** via `sitegen.yaml` for paths and preamble +2. **Frontmatter parsing** using goldmark/frontmatter +3. **Content scanning** with directory preservation +4. **HTML generation** using templ templates with embedded CSS +5. **llms.txt generation** with cleaned descriptions +6. **Full test coverage** including unit tests and E2E test + +The command can be run with: + +```bash +go run cmd/sitegen +go run cmd/sitegen --config custom.yaml +``` From ada6a244d3e8ebefb5dc2e558b1abbb29d32d9e3 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 16:04:34 +0000 Subject: [PATCH 17/31] chore: npm run format Signed-off-by: Xe Iaso --- CLAUDE.md | 2 +- conductor.json | 2 +- go.mod | 4 +++- go.sum | 4 ++++ package.json | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eef4bd2..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md \ No newline at end of file +@AGENTS.md diff --git a/conductor.json b/conductor.json index 835b285..69dd016 100644 --- a/conductor.json +++ b/conductor.json @@ -3,4 +3,4 @@ "setup": "npm ci && go mod download", "run": "go test ./..." } -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index a48e879..3ce939b 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ tool ( ) require ( + github.com/a-h/templ v0.3.977 github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.32.7 github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 @@ -28,6 +29,8 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/pstuifzand/ekster v0.0.0-20240904184605-72273498b4a6 github.com/tigrisdata/storage-go v0.4.0 + github.com/yuin/goldmark v1.7.13 + go.abhg.dev/goldmark/frontmatter v0.3.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -36,7 +39,6 @@ require ( github.com/BurntSushi/toml v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect - github.com/a-h/templ v0.3.977 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect diff --git a/go.sum b/go.sum index 16ccc54..c4e91bb 100644 --- a/go.sum +++ b/go.sum @@ -1076,11 +1076,15 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= +go.abhg.dev/goldmark/frontmatter v0.3.0 h1:ZOrMkeyyYzhlbenFNmOXyGFx1dFE8TgBWAgZfs9D5RA= +go.abhg.dev/goldmark/frontmatter v0.3.0/go.mod h1:W3KXvVveKKxU1FIFZ7fgFFQrlkcolnDcOVmu19cCO9U= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= diff --git a/package.json b/package.json index 05152b4..994858b 100644 --- a/package.json +++ b/package.json @@ -60,4 +60,4 @@ "trailingComma": "all", "printWidth": 80 } -} \ No newline at end of file +} From 566de35af2bcbd208724110a87f0886442a8795b Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 16:04:56 +0000 Subject: [PATCH 18/31] chore(popola): cleanups Signed-off-by: Xe Iaso --- cmd/popola/main.go | 4 ++-- cmd/popola/prompts/optimized.tmpl.txt | 3 ++- cmd/popola/sitegen.yaml | 6 ++++++ cmd/popola/testinput.json | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 cmd/popola/sitegen.yaml diff --git a/cmd/popola/main.go b/cmd/popola/main.go index 96d1f41..b6a54c8 100644 --- a/cmd/popola/main.go +++ b/cmd/popola/main.go @@ -105,9 +105,9 @@ func run(ctx context.Context) error { sess, err := client.Launch(claudecode.SessionConfig{ Query: promptBuilder.String(), OutputFormat: claudecode.OutputStreamJSON, - AllowedTools: []string{"mcp__webreader__*", "mcp__tigris-discord__*", "Bash(*)", "WebSearch", "Read", "Write", "Grep", "Glob", "Edit"}, + AllowedTools: []string{"mcp__web-reader__*", "mcp__tigris-discord__*", "Bash(*)", "Bash(find*)", "WebSearch", "Read", "Write", "Grep", "Glob", "Edit", "Update"}, // PermissionPromptTool: "mcp__approval__prompt-user", - AdditionalDirectories: []string{filepath.Join(cwd, "var", "*")}, + AdditionalDirectories: []string{filepath.Join(cwd, "var"), cwd}, Verbose: true, WorkingDir: cwd, diff --git a/cmd/popola/prompts/optimized.tmpl.txt b/cmd/popola/prompts/optimized.tmpl.txt index 0e94c71..8f26da5 100644 --- a/cmd/popola/prompts/optimized.tmpl.txt +++ b/cmd/popola/prompts/optimized.tmpl.txt @@ -15,10 +15,11 @@ Other useful documentation: 2. **When describing the migration feature behavior and setup steps, treat `docs/migration/` as the source of truth.** 3. If a detail is not explicitly supported by those sources, **do not invent it**—instead, write a safe, general statement or add a short note indicating the reader should verify the exact option/flag name in the docs. 4. NEVER reference llms.txt files in your output. Those are there for your reference, not for human readability. +5. DO NOT explore the repo. It is not relevant to your task. Only write the tutorial. ### Output requirements -* Save the resulting tutorial as a Markdown file under `./var` in a **sensible location that matches the existing folder structure** (e.g., `./var/tutorials/`, `./var/docs/`, `./var/blog/`, etc.). +* Save the resulting tutorial as a Markdown file under `./var` in a **sensible location that matches the existing folder structure** (e.g., `./var/input/tutorials/`, `./var/input/docs/`, `./var/input/blog/`, etc.). * If the appropriate folder does not exist, create it. * Choose a **descriptive filename** (kebab-case) that matches the tutorial title. * At the end of your response, print: diff --git a/cmd/popola/sitegen.yaml b/cmd/popola/sitegen.yaml new file mode 100644 index 0000000..123d509 --- /dev/null +++ b/cmd/popola/sitegen.yaml @@ -0,0 +1,6 @@ +content_dir: "./var/input" +output_dir: "./var/public" +preamble: | + # Tigris Tutorial Library + + This collection of tutorials is intended to be read by AI tooling. These pages are optimized for humans to read, but the main audience is generative engines (ChatGPT, Cursor, Zed, Claude Code, etc.). diff --git a/cmd/popola/testinput.json b/cmd/popola/testinput.json index fcd1449..72cb4cb 100644 --- a/cmd/popola/testinput.json +++ b/cmd/popola/testinput.json @@ -1,4 +1,4 @@ { - "topic": "Migrating data from Wasabi to Tigris", + "topic": "Migrating data from Cloudflare R2 to Tigris", "relevantDocs": "* https://www.tigrisdata.com/docs/migration/" } From 3656e5cd96a76660c83c405730bb7a2c2dd12b46 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 16:08:07 +0000 Subject: [PATCH 19/31] refactor(sitegen): simplify index page list to append to body Remove separate PageIndex template. For index.md files, just append the child page list to the end of the body HTML. Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- cmd/sitegen/base.templ | 7 -- cmd/sitegen/base_templ.go | 47 ++++++---- cmd/sitegen/css.go | 15 +++ cmd/sitegen/generate.go | 14 +-- cmd/sitegen/main.go | 2 + web/answerflow/answerflow_test.go | 16 ++-- web/discourse/discourse.go | 148 +++++++++++++++--------------- 7 files changed, 134 insertions(+), 115 deletions(-) diff --git a/cmd/sitegen/base.templ b/cmd/sitegen/base.templ index 3ce5560..0c1ee1b 100644 --- a/cmd/sitegen/base.templ +++ b/cmd/sitegen/base.templ @@ -24,11 +24,4 @@ templ PageView(title string, bodyHTML string) { } -templ PageIndex(title string, bodyHTML string, pageListHTML string) { -
-

{ title }

- @templ.Raw(pageListHTML) - @templ.Raw(bodyHTML) -
-} diff --git a/cmd/sitegen/base_templ.go b/cmd/sitegen/base_templ.go index b4d8c99..5cdc6fb 100644 --- a/cmd/sitegen/base_templ.go +++ b/cmd/sitegen/base_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.2.731 package main //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -13,9 +13,6 @@ import ( func Base(title string, content templ.Component) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { defer func() { @@ -31,20 +28,28 @@ func Base(title string, content templ.Component) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/sitegen/base.templ`, Line: 9, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `base.templ`, Line: 9, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = cssWriter{styles: siteCSS}.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -52,20 +57,17 @@ func Base(title string, content templ.Component) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - return nil + return templ_7745c5c3_Err }) } func PageView(title string, bodyHTML string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { defer func() { @@ -81,7 +83,20 @@ func PageView(title string, bodyHTML string) templ.Component { templ_7745c5c3_Var3 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `base.templ`, Line: 22, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -89,12 +104,10 @@ func PageView(title string, bodyHTML string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - return nil + return templ_7745c5c3_Err }) } - -var _ = templruntime.GeneratedTemplate diff --git a/cmd/sitegen/css.go b/cmd/sitegen/css.go index c97c95e..c52b7e5 100644 --- a/cmd/sitegen/css.go +++ b/cmd/sitegen/css.go @@ -1,8 +1,23 @@ package main import ( + "context" _ "embed" + "fmt" + "io" ) //go:embed site.css var siteCSS string + +type cssWriter struct { + styles string +} + +func (cw cssWriter) Render(_ context.Context, w io.Writer) error { + fmt.Fprintln(w, "") + + return nil +} diff --git a/cmd/sitegen/generate.go b/cmd/sitegen/generate.go index 04e8e9a..68eda29 100644 --- a/cmd/sitegen/generate.go +++ b/cmd/sitegen/generate.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "strings" - - "github.com/a-h/templ" ) func GeneratePages(cfg *Config, pages []*Page) error { @@ -39,8 +37,8 @@ func GeneratePages(cfg *Config, pages []*Page) error { return fmt.Errorf("creating directory: %w", err) } - // Build page list for index files - var content templ.Component + // Build page list for index files - append to body + bodyHTML := page.Frontmatter.Body if page.IsIndex { dir := filepath.Dir(page.URLPath) if dir == "." { @@ -56,13 +54,11 @@ func GeneratePages(cfg *Config, pages []*Page) error { listBuilder.WriteString(fmt.Sprintf("
  • %s: %s
  • ", href, cp.Frontmatter.Title, desc)) } listBuilder.WriteString("") - pageListHTML := listBuilder.String() - - content = PageIndex(page.Frontmatter.Title, page.Frontmatter.Body, pageListHTML) - } else { - content = PageView(page.Frontmatter.Title, page.Frontmatter.Body) + bodyHTML += listBuilder.String() } + content := PageView(page.Frontmatter.Title, bodyHTML) + // Render HTML baseTempl := Base(page.Frontmatter.Title, content) var buf bytes.Buffer diff --git a/cmd/sitegen/main.go b/cmd/sitegen/main.go index fb5d5d1..19043cb 100644 --- a/cmd/sitegen/main.go +++ b/cmd/sitegen/main.go @@ -6,6 +6,8 @@ import ( "log" ) +//go:generate go tool templ generate + var ( configPath = flag.String("config", "sitegen.yaml", "Path to sitegen.yaml configuration file") quiet = flag.Bool("quiet", false, "Suppress progress output") diff --git a/web/answerflow/answerflow_test.go b/web/answerflow/answerflow_test.go index 25f3f11..647932b 100644 --- a/web/answerflow/answerflow_test.go +++ b/web/answerflow/answerflow_test.go @@ -43,14 +43,14 @@ func skipIfNoCreds(t *testing.T) { func TestCreateSolution(t *testing.T) { tests := []struct { - name string - messageID string - solutionID string - server *httptest.Server - wantResponse *CreateSolutionResponse - wantErr bool - errContains string - isIntegration bool + name string + messageID string + solutionID string + server *httptest.Server + wantResponse *CreateSolutionResponse + wantErr bool + errContains string + isIntegration bool }{ { name: "successful solution creation", diff --git a/web/discourse/discourse.go b/web/discourse/discourse.go index 12a220c..bcc9e72 100644 --- a/web/discourse/discourse.go +++ b/web/discourse/discourse.go @@ -160,59 +160,59 @@ func GetTopic(ctx context.Context, u string) (*TopicResult, error) { } type TopicResult struct { - PostStream PostStream `json:"post_stream"` - TimelineLookup [][]int `json:"timeline_lookup"` - SuggestedTopics []Topics `json:"suggested_topics"` - Tags []string `json:"tags"` - TagsDescriptions any `json:"tags_descriptions"` - FancyTitle string `json:"fancy_title"` - ID int `json:"id"` - Title string `json:"title"` - PostsCount int `json:"posts_count"` - CreatedAt time.Time `json:"created_at"` - Views int `json:"views"` - ReplyCount int `json:"reply_count"` - LikeCount int `json:"like_count"` - LastPostedAt time.Time `json:"last_posted_at"` - Visible bool `json:"visible"` - Closed bool `json:"closed"` - Archived bool `json:"archived"` - HasSummary bool `json:"has_summary"` - Archetype string `json:"archetype"` - Slug string `json:"slug"` - CategoryID int `json:"category_id"` - WordCount int `json:"word_count"` - DeletedAt any `json:"deleted_at"` - UserID int `json:"user_id"` - FeaturedLink any `json:"featured_link"` - PinnedGlobally bool `json:"pinned_globally"` - PinnedAt any `json:"pinned_at"` - PinnedUntil any `json:"pinned_until"` - ImageURL any `json:"image_url"` - SlowModeSeconds int `json:"slow_mode_seconds"` - Draft any `json:"draft"` - DraftKey string `json:"draft_key,omitempty"` - DraftSequence any `json:"draft_sequence,omitempty"` - Unpinned any `json:"unpinned"` - Pinned bool `json:"pinned"` - CurrentPostNumber int `json:"current_post_number"` - HighestPostNumber int `json:"highest_post_number"` - DeletedBy any `json:"deleted_by"` - ActionsSummary []ActionsSummary `json:"actions_summary"` - ChunkSize int `json:"chunk_size"` - Bookmarked bool `json:"bookmarked"` - TopicTimer *TopicTimer `json:"topic_timer,omitempty"` - MessageBusLastID int `json:"message_bus_last_id"` - ParticipantCount int `json:"participant_count"` - ShowReadIndicator bool `json:"show_read_indicator"` - Thumbnails any `json:"thumbnails"` - SlowModeEnabledUntil any `json:"slow_mode_enabled_until"` - AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"` - CanVote bool `json:"can_vote"` - VoteCount int `json:"vote_count"` - UserVoted bool `json:"user_voted"` - Details TopicDetails `json:"details"` - Bookmarks any `json:"bookmarks"` + PostStream PostStream `json:"post_stream"` + TimelineLookup [][]int `json:"timeline_lookup"` + SuggestedTopics []Topics `json:"suggested_topics"` + Tags []string `json:"tags"` + TagsDescriptions any `json:"tags_descriptions"` + FancyTitle string `json:"fancy_title"` + ID int `json:"id"` + Title string `json:"title"` + PostsCount int `json:"posts_count"` + CreatedAt time.Time `json:"created_at"` + Views int `json:"views"` + ReplyCount int `json:"reply_count"` + LikeCount int `json:"like_count"` + LastPostedAt time.Time `json:"last_posted_at"` + Visible bool `json:"visible"` + Closed bool `json:"closed"` + Archived bool `json:"archived"` + HasSummary bool `json:"has_summary"` + Archetype string `json:"archetype"` + Slug string `json:"slug"` + CategoryID int `json:"category_id"` + WordCount int `json:"word_count"` + DeletedAt any `json:"deleted_at"` + UserID int `json:"user_id"` + FeaturedLink any `json:"featured_link"` + PinnedGlobally bool `json:"pinned_globally"` + PinnedAt any `json:"pinned_at"` + PinnedUntil any `json:"pinned_until"` + ImageURL any `json:"image_url"` + SlowModeSeconds int `json:"slow_mode_seconds"` + Draft any `json:"draft"` + DraftKey string `json:"draft_key,omitempty"` + DraftSequence any `json:"draft_sequence,omitempty"` + Unpinned any `json:"unpinned"` + Pinned bool `json:"pinned"` + CurrentPostNumber int `json:"current_post_number"` + HighestPostNumber int `json:"highest_post_number"` + DeletedBy any `json:"deleted_by"` + ActionsSummary []ActionsSummary `json:"actions_summary"` + ChunkSize int `json:"chunk_size"` + Bookmarked bool `json:"bookmarked"` + TopicTimer *TopicTimer `json:"topic_timer,omitempty"` + MessageBusLastID int `json:"message_bus_last_id"` + ParticipantCount int `json:"participant_count"` + ShowReadIndicator bool `json:"show_read_indicator"` + Thumbnails any `json:"thumbnails"` + SlowModeEnabledUntil any `json:"slow_mode_enabled_until"` + AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"` + CanVote bool `json:"can_vote"` + VoteCount int `json:"vote_count"` + UserVoted bool `json:"user_voted"` + Details TopicDetails `json:"details"` + Bookmarks any `json:"bookmarks"` } // JSONURL returns the relative path to the JSON API for this topic. @@ -294,11 +294,11 @@ type User struct { } type LinkCount struct { - URL string `json:"url"` - Internal bool `json:"internal"` - Reflection bool `json:"reflection"` - Title string `json:"title"` - Clicks int `json:"clicks"` + URL string `json:"url"` + Internal bool `json:"internal"` + Reflection bool `json:"reflection"` + Title string `json:"title"` + Clicks int `json:"clicks"` } type ActionsSummary struct { @@ -308,29 +308,29 @@ type ActionsSummary struct { } type TopicTimer struct { - ID int `json:"id"` - ExecuteAt time.Time `json:"execute_at"` - DurationMinutes int `json:"duration_minutes"` - BasedOnLastPost bool `json:"based_on_last_post"` - StatusType string `json:"status_type"` - CategoryID any `json:"category_id"` + ID int `json:"id"` + ExecuteAt time.Time `json:"execute_at"` + DurationMinutes int `json:"duration_minutes"` + BasedOnLastPost bool `json:"based_on_last_post"` + StatusType string `json:"status_type"` + CategoryID any `json:"category_id"` } type AcceptedAnswer struct { - PostNumber int `json:"post_number"` - Username string `json:"username"` - Name any `json:"name"` - Excerpt string `json:"excerpt"` + PostNumber int `json:"post_number"` + Username string `json:"username"` + Name any `json:"name"` + Excerpt string `json:"excerpt"` AccepterName any `json:"accepter_name"` } type TopicDetails struct { - CanEdit bool `json:"can_edit"` - NotificationLevel int `json:"notification_level"` - Participants []User `json:"participants"` - CreatedBy User `json:"created_by"` - LastPoster User `json:"last_poster"` - Links []TopicLink `json:"links"` + CanEdit bool `json:"can_edit"` + NotificationLevel int `json:"notification_level"` + Participants []User `json:"participants"` + CreatedBy User `json:"created_by"` + LastPoster User `json:"last_poster"` + Links []TopicLink `json:"links"` } type TopicLink struct { From 0f6091f3a87c8a3f71915ce8eb447328942a8bd4 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 16:11:01 +0000 Subject: [PATCH 20/31] feat(sitegen): root index shows all non-index pages For the root index.md, show ALL non-index pages from all directories on one page. Subdirectory indexes still only show their local pages. Also add index.md files for migration/ and tutorials/. Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- cmd/sitegen/generate.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/sitegen/generate.go b/cmd/sitegen/generate.go index 68eda29..f9ffdc0 100644 --- a/cmd/sitegen/generate.go +++ b/cmd/sitegen/generate.go @@ -44,7 +44,15 @@ func GeneratePages(cfg *Config, pages []*Page) error { if dir == "." { dir = "" } - childPages := dirPages[dir] + var childPages []*Page + if dir == "" { + // Root index: show ALL non-index pages + for _, pages := range dirPages { + childPages = append(childPages, pages...) + } + } else { + childPages = dirPages[dir] + } var listBuilder strings.Builder listBuilder.WriteString("
      ") From 4881faa2597dba6059d0ed8789d4f33feae969bf Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 4 Feb 2026 16:44:22 +0000 Subject: [PATCH 21/31] docs(popola): rewrite README with clear usage instructions Replace the minimal README with a complete description based on source code analysis. Includes input format, output behavior, how it works, flags reference, and usage example. Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- cmd/popola/README.md | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/cmd/popola/README.md b/cmd/popola/README.md index d38e219..6e8e456 100644 --- a/cmd/popola/README.md +++ b/cmd/popola/README.md @@ -1,5 +1,42 @@ # Popola - +Popola generates Tigris tutorials autonomously. It reads a topic from stdin, consults documentation, and instructs Claude to write a hands-on tutorial. -Popola is a semi-autonomous agent that will assemble tutorials for Tigris that are explicitly aimed at helping AI agents use Tigris better. +## Input + +Pipe JSON to stdin with two fields: + +```json +{ + "topic": "Migrating data from Hetzner object storage to Tigris", + "relevantDocs": "https://www.tigrisdata.com/docs/migration/\n* https://docs.hetzner.com/storage/object-storage/getting-started/using-s3-api-tools/" +} +``` + +Both fields are required. + +## Output + +The agent writes Markdown tutorials to `./var` in a sensible location, then prints the final file path. + +## How it works + +Popola hydrates a prompt template with your input, then launches a Claude Code session. The agent can read the web and query the Tigris community Discord. It writes the tutorial, revises it for clarity and style, then saves it to disk. + +Tool usage and events stream to stdout as JSON lines. + +## Flags + +| Flag | Description | Default | +| ----------------------- | ---------------------------- | ------------------------ | +| `-anthropic-auth-token` | Anthropic API token | `hunter2` | +| `-anthropic-base-url` | Anthropic API base URL | `http://localhost:11434` | +| `-anthropic-model` | Model to use | `glm-4.7-flash:latest` | +| `-zhipu-api-key` | Zhipu API key for web reader | (empty) | + +## Example + +```bash +echo '{"topic":"Migrating from S3 to Tigris","relevantDocs":"* https://www.tigrisdata.com/docs/migration/"}' | \ + go run ./cmd/popola +``` From 6a4fd329d6c4d61fba35a5d1fad00adce61d89d2 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 5 Feb 2026 13:33:00 +0000 Subject: [PATCH 22/31] feat(popola): add output folder flag and improve tool logging - Add --output-folder flag (default: ./var) for generated content - Template output folder in optimized.tmpl.txt prompt - Add todo.go for parsing TodoWrite tool inputs from map[string]any - Improve tool logging with nice formatting: - TodoWrite: logs each todo with status and content - mcp__web-reader__webReader: logs URL being fetched - Read/Write/Edit: log file path instead of full JSON - Update testinput.json for AWS Node.js tutorial - Update sitegen.yaml preamble for generative agents Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- cmd/popola/main.go | 48 ++++++++++++++- cmd/popola/prompts/optimized.tmpl.txt | 9 ++- cmd/popola/sitegen.yaml | 2 +- cmd/popola/testinput.json | 4 +- cmd/popola/todo.go | 86 +++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 cmd/popola/todo.go diff --git a/cmd/popola/main.go b/cmd/popola/main.go index b6a54c8..6c74746 100644 --- a/cmd/popola/main.go +++ b/cmd/popola/main.go @@ -9,7 +9,6 @@ import ( "fmt" "log/slog" "os" - "path/filepath" "strings" "text/template" @@ -24,6 +23,7 @@ var ( anthropicBaseURL = flag.String("anthropic-base-url", "http://localhost:11434", "Anthropic API base URL") anthropicModel = flag.String("anthropic-model", "glm-4.7-flash:latest", "Anthropic AI model to use for all levels of agentic function") zhipuAPIKey = flag.String("zhipu-api-key", "", "API key for z.ai (Zhipu)") + outputFolder = flag.String("output-folder", "./var", "Output folder for generated content") //go:embed prompts/*.tmpl.txt prompts embed.FS @@ -35,6 +35,7 @@ var ( type Input struct { Topic string `json:"topic"` RelevantDocs string `json:"relevantDocs"` + OutputFolder string `json:"outputFolder"` } func (i Input) Valid() error { @@ -85,6 +86,8 @@ func run(ctx context.Context) error { return fmt.Errorf("can't validate input JSON: %w", err) } + input.OutputFolder = *outputFolder + var promptBuilder strings.Builder tmpl, err := template.ParseFS(prompts, "prompts/*.tmpl.txt") @@ -107,7 +110,7 @@ func run(ctx context.Context) error { OutputFormat: claudecode.OutputStreamJSON, AllowedTools: []string{"mcp__web-reader__*", "mcp__tigris-discord__*", "Bash(*)", "Bash(find*)", "WebSearch", "Read", "Write", "Grep", "Glob", "Edit", "Update"}, // PermissionPromptTool: "mcp__approval__prompt-user", - AdditionalDirectories: []string{filepath.Join(cwd, "var"), cwd}, + AdditionalDirectories: []string{*outputFolder, cwd}, Verbose: true, WorkingDir: cwd, @@ -151,7 +154,46 @@ func run(ctx context.Context) error { for _, part := range event.Message.Content { switch part.Type { case "tool_use": - lg.Info("using tool", "tool", part.Name, "input", part.Input) + switch part.Name { + case "TodoWrite": + inputMap := make(map[string]any, len(part.Input)) + for k, v := range part.Input { + inputMap[k] = v + } + if todoList, err := ParseTodoFromMap(inputMap); err == nil { + for _, todo := range todoList.Todos { + lg.Info("todo", "status", todo.Status, "content", todo.Content) + } + } else { + lg.Info("using tool", "tool", part.Name, "input", part.Input) + } + case "mcp__web-reader__webReader": + if url, ok := part.Input["url"].(string); ok { + lg.Info("fetching docs", "url", url) + } else { + lg.Info("using tool", "tool", part.Name, "input", part.Input) + } + case "Read": + if path, ok := part.Input["file_path"].(string); ok { + lg.Info("reading file", "path", path) + } else { + lg.Info("using tool", "tool", part.Name, "input", part.Input) + } + case "Write": + if path, ok := part.Input["file_path"].(string); ok { + lg.Info("writing file", "path", path) + } else { + lg.Info("using tool", "tool", part.Name, "input", part.Input) + } + case "Edit": + if path, ok := part.Input["file_path"].(string); ok { + lg.Info("editing file", "path", path) + } else { + lg.Info("using tool", "tool", part.Name, "input", part.Input) + } + default: + lg.Info("using tool", "tool", part.Name, "input", part.Input) + } case "tool_result": lg.Info("tool result", "tool", part.Name) json.NewEncoder(os.Stdout).Encode(part) diff --git a/cmd/popola/prompts/optimized.tmpl.txt b/cmd/popola/prompts/optimized.tmpl.txt index 8f26da5..3007b6c 100644 --- a/cmd/popola/prompts/optimized.tmpl.txt +++ b/cmd/popola/prompts/optimized.tmpl.txt @@ -16,10 +16,11 @@ Other useful documentation: 3. If a detail is not explicitly supported by those sources, **do not invent it**—instead, write a safe, general statement or add a short note indicating the reader should verify the exact option/flag name in the docs. 4. NEVER reference llms.txt files in your output. Those are there for your reference, not for human readability. 5. DO NOT explore the repo. It is not relevant to your task. Only write the tutorial. +6. When fetching information from the web, use the `mcp__web-reader__webReader tool. ### Output requirements -* Save the resulting tutorial as a Markdown file under `./var` in a **sensible location that matches the existing folder structure** (e.g., `./var/input/tutorials/`, `./var/input/docs/`, `./var/input/blog/`, etc.). +* Save the resulting tutorial as a Markdown file under `{{ .OutputFolder }}` in a **sensible location that matches the existing folder structure** (e.g., `{{ .OutputFolder }}/input/tutorials/`, `{{ .OutputFolder }}/input/docs/`, `{{ .OutputFolder }}/input/blog/`, etc.). * If the appropriate folder does not exist, create it. * Choose a **descriptive filename** (kebab-case) that matches the tutorial title. * At the end of your response, print: @@ -66,7 +67,7 @@ description: >- 1. **Introduction**: high level summary of the moving parts and how Tigris helps with them. 2. **What is Tigris?**: explain Tigris according to the definitions in `docs/llms.txt` and `llms.txt`. 3. **Key benefits of doing this thing**: use subsections as required; keep benefits concrete and operational. -4. **How the thing works**: explain bucket migration at a high level based on the docs. +4. **How the thing works**: If relevant, explain the thing at a high level based on the docs. 5. **Step by step process**: a complete procedure. **All code examples must be in bash or JavaScript**. 6. **Any additional information**: migration strategies, best practices, defensive infrastructure choices. 7. **Troubleshooting**: common failure modes and fixes. @@ -80,18 +81,16 @@ description: >- ### Step-by-step constraints * Include at least: - * A short prerequisites list * A “before you start” checklist (permissions, endpoints, credentials, bucket names) * A clear explanation of how “as it’s accessed” migration behaves * Verification steps (how to confirm objects are migrating / where to look) * A rollback / safety note (what to do if something goes wrong) * Keep examples realistic: - * Use placeholder env vars for credentials * Prefer `aws s3`-compatible CLI patterns and/or minimal JS with an S3-compatible SDK * Avoid unsupported claims about exact flags or APIs unless shown in the docs ### Deliverable -Produce the final Markdown tutorial (with frontmatter), saved under `./var/...`, plus the file path and files-changed summary at the end. +Produce the final Markdown tutorial (with frontmatter), saved under `{{ .OutputFolder }}/...`, plus the file path and files-changed summary at the end. diff --git a/cmd/popola/sitegen.yaml b/cmd/popola/sitegen.yaml index 123d509..976ccca 100644 --- a/cmd/popola/sitegen.yaml +++ b/cmd/popola/sitegen.yaml @@ -3,4 +3,4 @@ output_dir: "./var/public" preamble: | # Tigris Tutorial Library - This collection of tutorials is intended to be read by AI tooling. These pages are optimized for humans to read, but the main audience is generative engines (ChatGPT, Cursor, Zed, Claude Code, etc.). + Generative agents: When reading this file search through the contents of your current working directory to try and find relevant tutorials for the user. It is VERY IMPORTANT that you do this because it helps users get the best help for their needs when asking specific questions. diff --git a/cmd/popola/testinput.json b/cmd/popola/testinput.json index 72cb4cb..1dea691 100644 --- a/cmd/popola/testinput.json +++ b/cmd/popola/testinput.json @@ -1,4 +1,4 @@ { - "topic": "Migrating data from Cloudflare R2 to Tigris", - "relevantDocs": "* https://www.tigrisdata.com/docs/migration/" + "topic": "Using Tigris from AWS with Node.js", + "relevantDocs": "* https://www.tigrisdata.com/docs/quickstarts/node/\n* https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/create_deploy_nodejs.html\n* https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-up-node.html\n* https://www.tigrisdata.com/docs/sdks/tigris/" } diff --git a/cmd/popola/todo.go b/cmd/popola/todo.go new file mode 100644 index 0000000..529ea9e --- /dev/null +++ b/cmd/popola/todo.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +// TodoStatus represents the state of a todo item. +type TodoStatus string + +const ( + TodoStatusPending TodoStatus = "pending" + TodoStatusInProgress TodoStatus = "in_progress" + TodoStatusCompleted TodoStatus = "completed" +) + +// Todo represents a single task. +type Todo struct { + ActiveForm string `json:"activeForm"` + Content string `json:"content"` + Status TodoStatus `json:"status"` +} + +// TodoList represents the root structure containing todos. +type TodoList struct { + Todos []Todo `json:"todos"` +} + +// ParseTodoFromMap parses a todo list from a map[string]any structure. +// Expected input format: +// +// map[string]any{ +// "todos": []any{ +// map[string]any{"activeForm": "...", "content": "...", "status": "..."}, +// ... +// }, +// } +func ParseTodoFromMap(m map[string]any) (*TodoList, error) { + todosAny, ok := m["todos"] + if !ok { + return nil, fmt.Errorf("missing 'todos' key in map") + } + + todosSlice, ok := todosAny.([]any) + if !ok { + return nil, fmt.Errorf("'todos' is not a slice: %T", todosAny) + } + + var todos []Todo + for i, item := range todosSlice { + todoMap, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("todo item %d is not a map: %T", i, item) + } + + todo := Todo{} + if v, ok := todoMap["activeForm"].(string); ok { + todo.ActiveForm = v + } + if v, ok := todoMap["content"].(string); ok { + todo.Content = v + } + if v, ok := todoMap["status"].(string); ok { + todo.Status = TodoStatus(v) + } + + todos = append(todos, todo) + } + + return &TodoList{Todos: todos}, nil +} + +// MarshalJSON implements json.Marshaler. +func (t TodoStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(string(t)) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (t *TodoStatus) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *t = TodoStatus(s) + return nil +} From 85d0e5c7bf00f12c1460ec6f648d3286bef6810c Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 5 Feb 2026 14:08:04 +0000 Subject: [PATCH 23/31] feat(popola): auto-commit generated content to git - Add go-git dependency for git operations from Go code - Add commitChanges function that: - Opens git repo in output folder - Finds changed files in output folder - Stages and commits them with "docs: " message - Includes Assisted-by and Signed-off-by footers - Call commitChanges after agent succeeds - Add npm run build script that outputs to ./var/bin - Update AGENTS.md to document build output location Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- AGENTS.md | 15 +++++---- cmd/popola/main.go | 82 ++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 17 ++++++++++ go.sum | 38 +++++++++++++++++++++ package.json | 3 +- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f61bb2b..30b527e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,15 @@ This is a repository full of code written in Go. Use the table driven testing sk ### Build, Test & Development Commands -| Command | Description | -| ---------------- | -------------------------------------------------- | -| `npm test` | Runs tests with `go test ./...`. | -| `go build ./...` | Compiles all Go packages. | -| `go test ./...` | Runs all tests. | -| `npm run format` | Formats Go (`goimports`) and JS/HTML (`prettier`). | +| Command | Description | +| ---------------- | ----------------------------------------------------------- | +| `npm run build` | Builds all Go packages, outputting binaries to `./var/bin`. | +| `npm test` | Runs tests with `go test ./...`. | +| `go build ./...` | Compiles all Go packages. | +| `go test ./...` | Runs all tests. | +| `npm run format` | Formats Go (`goimports`) and JS/HTML (`prettier`). | + +**Important**: When building Go binaries, always output to `./var/bin` (e.g., `go build -o ./var/bin ./cmd/popola`). This keeps build artifacts out of the repository root. ### Code Formatting & Style diff --git a/cmd/popola/main.go b/cmd/popola/main.go index 6c74746..b8be109 100644 --- a/cmd/popola/main.go +++ b/cmd/popola/main.go @@ -9,10 +9,14 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "strings" "text/template" + "time" "github.com/facebookgo/flagenv" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" claudecode "github.com/humanlayer/humanlayer/claudecode-go" _ "github.com/joho/godotenv/autoload" @@ -30,6 +34,7 @@ var ( ErrNoInputTopic = errors.New("no topic defined") ErrNoRelevantDocs = errors.New("no relevant documentation defined") + NoChangesToCommit = errors.New("no changes to commit") ) type Input struct { @@ -56,6 +61,74 @@ func (i Input) Valid() error { return nil } +func commitChanges(repoPath, outputFolder, topic string) error { + repo, err := git.PlainOpen(repoPath) + if err != nil { + return fmt.Errorf("can't open git repo: %w", err) + } + + worktree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("can't get worktree: %w", err) + } + + // Get status to find changes in output folder + status, err := worktree.Status() + if err != nil { + return fmt.Errorf("can't get git status: %w", err) + } + + // Collect files in output folder that have changes + var filesToCommit []string + outputFolderAbs, err := filepath.Abs(outputFolder) + if err != nil { + return fmt.Errorf("can't get absolute path for output folder: %w", err) + } + + for file, st := range status { + if st.Worktree == git.Unmodified { + continue + } + // Check if file is in or under the output folder + absPath, err := filepath.Abs(file) + if err != nil { + continue + } + if strings.HasPrefix(absPath, outputFolderAbs) || strings.HasPrefix(file, outputFolder) { + filesToCommit = append(filesToCommit, file) + } + } + + if len(filesToCommit) == 0 { + return NoChangesToCommit + } + + // Add all changed files in output folder + for _, file := range filesToCommit { + if _, err := worktree.Add(file); err != nil { + return fmt.Errorf("can't stage file %s: %w", file, err) + } + } + + // Create commit message + commitMsg := fmt.Sprintf("docs: %s\n\nAssisted-by: GLM 4.7 via Claude Code\nSigned-off-by: Xe Iaso ", topic) + + // Commit + _, err = worktree.Commit(commitMsg, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Xe Iaso", + Email: "xe@tigrisdata.com", + When: time.Now(), + }, + }) + if err != nil { + return fmt.Errorf("can't commit: %w", err) + } + + slog.Info("committed changes", "output_folder", outputFolder, "files", len(filesToCommit), "topic", topic) + return nil +} + func main() { flagenv.Parse() flag.Parse() @@ -219,5 +292,14 @@ func run(ctx context.Context) error { lg.Info("got result", "session", sess.ID, "type", result.Type, "subtype", result.Subtype, "cost_usd", result.CostUSD, "duration_ms", result.DurationMS, "num_turns", result.NumTurns) } + // Commit any changes made in the output folder + if err := commitChanges(*outputFolder, *outputFolder, input.Topic); err != nil { + if errors.Is(err, NoChangesToCommit) { + slog.Info("no changes to commit in output folder") + } else { + slog.Error("failed to commit changes", "err", err) + } + } + return nil } diff --git a/go.mod b/go.mod index 3ce939b..c82a6ce 100644 --- a/go.mod +++ b/go.mod @@ -36,8 +36,11 @@ require ( require ( charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251120230642-dcccabe2cd63 // indirect + dario.cat/mergo v1.0.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect @@ -70,14 +73,23 @@ require ( github.com/clipperhouse/displaywidth v0.5.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/ebitengine/purego v0.8.3 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-git/v5 v5.16.4 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/jsonschema-go v0.3.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -86,15 +98,19 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/natefinch/atomic v1.0.1 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect @@ -109,5 +125,6 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.41.0 // indirect google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect honnef.co/go/tools v0.6.1 // indirect ) diff --git a/go.sum b/go.sum index c4e91bb..f69d36e 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k= @@ -79,6 +81,9 @@ github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JP github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ= @@ -91,6 +96,8 @@ github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5 github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= @@ -273,6 +280,8 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -390,6 +399,8 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ= github.com/d2g/dhcp4client v1.0.0/go.mod h1:j0hNfjhrt2SxUOw55nL0ATM/z4Yt3t2Kd1mW34z5W5s= @@ -426,6 +437,8 @@ github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -474,6 +487,12 @@ github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0 github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= +github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -551,6 +570,8 @@ github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4er github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= @@ -718,6 +739,8 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= @@ -748,6 +771,8 @@ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALr github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -905,6 +930,8 @@ github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2 github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -974,6 +1001,8 @@ github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiB github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -987,6 +1016,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/snowflakedb/gosnowflake v1.6.3/go.mod h1:6hLajn6yxuJ4xUHZegMekpq9rnQbGJ7TMwXjgTmA6lg= @@ -1058,6 +1089,8 @@ github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1 github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= @@ -1136,6 +1169,7 @@ golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1246,6 +1280,7 @@ golang.org/x/net v0.0.0-20210505214959-0714010a04ed/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211013171255-e13a2654a71e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1378,6 +1413,7 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210818153620-00dd8d7831e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220823224334-20c2bfdbfe24/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1645,6 +1681,8 @@ gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76 gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/package.json b/package.json index 994858b..c467e04 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "scripts": { "test": "go test ./...", "format": "go tool goimports -w . && prettier -w .", - "prepare": "husky" + "prepare": "husky", + "build": "mkdir -p ./var/bin && go build -o ./var/bin ./cmd/..." }, "devDependencies": { "@commitlint/cli": "^20.3.1", From 3fa5ad1ba821151b467be1704d116d44196dcde2 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 5 Feb 2026 14:08:33 +0000 Subject: [PATCH 24/31] fix(sitegen): use MDPath for llms.txt links instead of URLPath - Add MDPath field to Page struct for markdown file links - URLPath now contains HTML links (.html extension) - MDPath contains markdown links (.md extension) - Update llms.txt generation to use MDPath for references - Update tests to use MDPath field Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- cmd/sitegen/llms.go | 2 +- cmd/sitegen/llms_test.go | 6 +++--- cmd/sitegen/scan.go | 6 ++++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/sitegen/llms.go b/cmd/sitegen/llms.go index 592da07..4bc1386 100644 --- a/cmd/sitegen/llms.go +++ b/cmd/sitegen/llms.go @@ -39,7 +39,7 @@ func GenerateLLMsTxt(cfg *Config, pages []*Page) error { } title := page.Frontmatter.Title - link := page.URLPath + link := page.MDPath desc := cleanDescription(page.Frontmatter.Description) sb.WriteString(fmt.Sprintf("* [%s](%s): %s\n", title, link, desc)) diff --git a/cmd/sitegen/llms_test.go b/cmd/sitegen/llms_test.go index e09733b..340ded4 100644 --- a/cmd/sitegen/llms_test.go +++ b/cmd/sitegen/llms_test.go @@ -22,21 +22,21 @@ func TestGenerateLLMsTxt(t *testing.T) { pages := []*Page{ { - URLPath: "./index.md", + MDPath: "./index.md", Frontmatter: &Frontmatter{ Title: "Main Page", Description: "The main index page", }, }, { - URLPath: "./guide/setup.md", + MDPath: "./guide/setup.md", Frontmatter: &Frontmatter{ Title: "Setup Guide", Description: "How to get started", }, }, { - URLPath: "./api/reference.md", + MDPath: "./api/reference.md", Frontmatter: &Frontmatter{ Title: "API Reference", Description: "API docs with extra spaces\nand newlines", diff --git a/cmd/sitegen/scan.go b/cmd/sitegen/scan.go index c2abbe7..22dcd6c 100644 --- a/cmd/sitegen/scan.go +++ b/cmd/sitegen/scan.go @@ -10,7 +10,8 @@ type Page struct { InputPath string // Full path to source .md file OutputPath string // Full path to output .html file OutputMD string // Full path to copied .md file - URLPath string // Relative path for linking (e.g., "./guide/setup.md") + URLPath string // Relative path for HTML linking (e.g., "./guide/setup.html") + MDPath string // Relative path for md linking (e.g., "./guide/setup.md") IsIndex bool // True if filename is index.md Frontmatter *Frontmatter } @@ -36,7 +37,8 @@ func ScanContent(contentDir string) ([]*Page, error) { pages = append(pages, &Page{ InputPath: path, - URLPath: "./" + relPath, + URLPath: "./" + strings.TrimSuffix(relPath, ".md") + ".html", + MDPath: "./" + relPath, IsIndex: filepath.Base(path) == "index.md", }) From 4ab26cc4beddd2f5cc6c25a8c2d84e0fbf91ba84 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 5 Feb 2026 16:13:55 +0000 Subject: [PATCH 25/31] fix(popola): rename NoChangesToCommit to ErrNoChangesToCommit Follow Go naming conventions for error variables (ST1012). Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- cmd/popola/main.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/popola/main.go b/cmd/popola/main.go index b8be109..d7b9489 100644 --- a/cmd/popola/main.go +++ b/cmd/popola/main.go @@ -32,9 +32,9 @@ var ( //go:embed prompts/*.tmpl.txt prompts embed.FS - ErrNoInputTopic = errors.New("no topic defined") - ErrNoRelevantDocs = errors.New("no relevant documentation defined") - NoChangesToCommit = errors.New("no changes to commit") + ErrNoInputTopic = errors.New("no topic defined") + ErrNoRelevantDocs = errors.New("no relevant documentation defined") + ErrNoChangesToCommit = errors.New("no changes to commit") ) type Input struct { @@ -100,7 +100,7 @@ func commitChanges(repoPath, outputFolder, topic string) error { } if len(filesToCommit) == 0 { - return NoChangesToCommit + return ErrNoChangesToCommit } // Add all changed files in output folder @@ -294,7 +294,7 @@ func run(ctx context.Context) error { // Commit any changes made in the output folder if err := commitChanges(*outputFolder, *outputFolder, input.Topic); err != nil { - if errors.Is(err, NoChangesToCommit) { + if errors.Is(err, ErrNoChangesToCommit) { slog.Info("no changes to commit in output folder") } else { slog.Error("failed to commit changes", "err", err) From 9d089cc9ea227ca4582e269159cf5dc5d66852d7 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 5 Feb 2026 17:26:26 +0000 Subject: [PATCH 26/31] docs(popola): clean up redundant prompt rules Merge duplicate rules about not exploring the repo and using web-reader. Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- cmd/popola/prompts/optimized.tmpl.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/popola/prompts/optimized.tmpl.txt b/cmd/popola/prompts/optimized.tmpl.txt index 3007b6c..a930142 100644 --- a/cmd/popola/prompts/optimized.tmpl.txt +++ b/cmd/popola/prompts/optimized.tmpl.txt @@ -15,8 +15,7 @@ Other useful documentation: 2. **When describing the migration feature behavior and setup steps, treat `docs/migration/` as the source of truth.** 3. If a detail is not explicitly supported by those sources, **do not invent it**—instead, write a safe, general statement or add a short note indicating the reader should verify the exact option/flag name in the docs. 4. NEVER reference llms.txt files in your output. Those are there for your reference, not for human readability. -5. DO NOT explore the repo. It is not relevant to your task. Only write the tutorial. -6. When fetching information from the web, use the `mcp__web-reader__webReader tool. +5. When fetching information from the web, use the `mcp__web-reader__webReader tool. ### Output requirements From 014332b5ae559d8bbf6a43bde0d57c9ec9d303f6 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 5 Feb 2026 17:26:35 +0000 Subject: [PATCH 27/31] test(popola): update test topic for AWS SDK to Tigris SDK migration Change test input to cover switching from AWS SDK to Tigris SDK in JavaScript, referencing the Tigris storage README and AWS SDK v3 docs. Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- cmd/popola/testinput.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/popola/testinput.json b/cmd/popola/testinput.json index 1dea691..30888b5 100644 --- a/cmd/popola/testinput.json +++ b/cmd/popola/testinput.json @@ -1,4 +1,4 @@ { - "topic": "Using Tigris from AWS with Node.js", - "relevantDocs": "* https://www.tigrisdata.com/docs/quickstarts/node/\n* https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/create_deploy_nodejs.html\n* https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-up-node.html\n* https://www.tigrisdata.com/docs/sdks/tigris/" + "topic": "Switching from AWS SDK to Tigris SDK in JavaScript", + "relevantDocs": "* https://raw.githubusercontent.com/tigrisdata/storage/refs/heads/main/packages/storage/README.md\n* https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/\n* https://www.tigrisdata.com/docs/sdks/tigris/" } From 3357eb74e9a6a0cdc7c370f621bad0e38bd4fe8b Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 5 Feb 2026 17:26:42 +0000 Subject: [PATCH 28/31] refactor(popola): use output folder as working directory Set the agent's working directory to the output folder instead of the repo root, so all generated content goes directly to the target location. Assisted-by: GLM 4.7 via Claude Code Signed-off-by: Xe Iaso --- cmd/popola/main.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/popola/main.go b/cmd/popola/main.go index d7b9489..ada70af 100644 --- a/cmd/popola/main.go +++ b/cmd/popola/main.go @@ -176,16 +176,14 @@ func run(ctx context.Context) error { return fmt.Errorf("can't open Claude Code: %w", err) } - cwd, _ := os.Getwd() - sess, err := client.Launch(claudecode.SessionConfig{ Query: promptBuilder.String(), OutputFormat: claudecode.OutputStreamJSON, AllowedTools: []string{"mcp__web-reader__*", "mcp__tigris-discord__*", "Bash(*)", "Bash(find*)", "WebSearch", "Read", "Write", "Grep", "Glob", "Edit", "Update"}, // PermissionPromptTool: "mcp__approval__prompt-user", - AdditionalDirectories: []string{*outputFolder, cwd}, + AdditionalDirectories: []string{*outputFolder}, Verbose: true, - WorkingDir: cwd, + WorkingDir: *outputFolder, MCPConfig: &claudecode.MCPConfig{ MCPServers: map[string]claudecode.MCPServer{ From 2116a343928dffcfe9f54d6d92f952f081aa4a75 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 9 Feb 2026 17:09:14 +0000 Subject: [PATCH 29/31] feat(popola): add SEO/AEO skill Signed-off-by: Xe Iaso --- .../skills/seo-aeo-best-practices/SKILL.md | 46 +++++ .../resources/aeo-considerations.md | 160 ++++++++++++++++++ .../resources/eeat-principles.md | 146 ++++++++++++++++ .../resources/structured-data.md | 150 ++++++++++++++++ .../resources/technical-seo.md | 152 +++++++++++++++++ cmd/popola/prompts/optimized.tmpl.txt | 1 + cmd/popola/testinput.json | 4 +- 7 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 cmd/popola/.claude/skills/seo-aeo-best-practices/SKILL.md create mode 100644 cmd/popola/.claude/skills/seo-aeo-best-practices/resources/aeo-considerations.md create mode 100644 cmd/popola/.claude/skills/seo-aeo-best-practices/resources/eeat-principles.md create mode 100644 cmd/popola/.claude/skills/seo-aeo-best-practices/resources/structured-data.md create mode 100644 cmd/popola/.claude/skills/seo-aeo-best-practices/resources/technical-seo.md diff --git a/cmd/popola/.claude/skills/seo-aeo-best-practices/SKILL.md b/cmd/popola/.claude/skills/seo-aeo-best-practices/SKILL.md new file mode 100644 index 0000000..843f347 --- /dev/null +++ b/cmd/popola/.claude/skills/seo-aeo-best-practices/SKILL.md @@ -0,0 +1,46 @@ +--- +name: seo-aeo-best-practices +description: SEO and AEO (Answer Engine Optimization) best practices including EEAT principles, structured data, and technical SEO. Use when implementing metadata, sitemaps, structured data, or optimizing content for search engines and AI assistants. +license: MIT +metadata: + author: sanity + version: "1.0.0" +--- + +# SEO & AEO Best Practices + +Principles for optimizing content for both traditional search engines (SEO) and AI-powered answer engines (AEO). Includes Google's EEAT guidelines and structured data implementation. + +## When to Apply + +Reference these guidelines when: + +- Implementing metadata and Open Graph tags +- Creating sitemaps and robots.txt +- Adding JSON-LD structured data +- Optimizing content for featured snippets +- Preparing content for AI assistants (ChatGPT, Perplexity, etc.) +- Evaluating content quality using EEAT principles + +## Core Concepts + +### SEO (Search Engine Optimization) + +Optimizing content to rank well in traditional search results (Google, Bing). + +### AEO (Answer Engine Optimization) + +Optimizing content to be selected as authoritative answers by AI systems. + +### EEAT (Experience, Expertise, Authoritativeness, Trustworthiness) + +Google's framework for evaluating content quality. + +## Resources + +See `resources/` for detailed guidance: + +- EEAT implementation +- Structured data patterns +- Technical SEO checklist +- AI/AEO considerations diff --git a/cmd/popola/.claude/skills/seo-aeo-best-practices/resources/aeo-considerations.md b/cmd/popola/.claude/skills/seo-aeo-best-practices/resources/aeo-considerations.md new file mode 100644 index 0000000..e79ea7a --- /dev/null +++ b/cmd/popola/.claude/skills/seo-aeo-best-practices/resources/aeo-considerations.md @@ -0,0 +1,160 @@ +# AI/AEO Considerations + +Answer Engine Optimization (AEO) prepares content to be selected as authoritative answers by AI systems like ChatGPT, Perplexity, Google AI Overviews, and Bing Copilot. + +## How AI Selects Answers + +AI systems evaluate content based on: + +1. **Clarity:** Is the answer direct and easy to extract? +2. **Authority:** Is the source trustworthy? +3. **Comprehensiveness:** Does it fully address the question? +4. **Recency:** Is the information up to date? +5. **Structure:** Can the AI parse and understand it? + +## Content Structure for AI + +### Direct Answers First + +Lead with the answer, then explain. + +**Bad:** + +> The history of JavaScript dates back to 1995 when Brendan Eich... [500 words later] ...JavaScript runs in the browser. + +**Good:** + +> JavaScript is a programming language that runs in web browsers. It was created in 1995 by Brendan Eich... + +### Clear Headings + +Use descriptive H2/H3 headings that match user questions. + +**Bad:** "Overview" → "Details" → "More Information" +**Good:** "What is X?" → "How does X work?" → "When should you use X?" + +### Lists and Tables + +AI extracts structured information more easily than prose. + +```markdown +## Benefits of Structured Content + +- **Reusability:** Use content across channels +- **Flexibility:** Change presentation without changing content +- **Scalability:** Manage large content volumes +``` + +### FAQ Format + +Question-answer pairs are ideal for AI extraction. + +```typescript +// Schema for AI-friendly FAQs +defineType({ + name: "faq", + type: "document", + fields: [ + defineField({ name: "question", type: "string" }), + defineField({ name: "answer", type: "text" }), + defineField({ + name: "category", + type: "reference", + to: [{ type: "faqCategory" }], + }), + ], +}); +``` + +## Technical Implementation + +### Structured Data (Critical) + +JSON-LD helps AI understand content type and relationships. + +```typescript +// FAQ structured data +const faqSchema = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: faqs.map((faq) => ({ + "@type": "Question", + name: faq.question, + acceptedAnswer: { + "@type": "Answer", + text: faq.answer, + }, + })), +}; +``` + +### Canonical Content + +Ensure AI finds your authoritative version, not copies. + +- Set canonical URLs +- Avoid duplicate content across pages +- Use `rel="canonical"` for syndicated content + +### Freshness Signals + +AI systems prefer current information. + +- Display publish and update dates prominently +- Update content regularly (even small updates signal freshness) +- Use `dateModified` in structured data + +## Content Quality Signals + +### Author Credentials + +AI systems increasingly check author authority. + +- Display author name and credentials +- Link to author profiles +- Include author structured data + +### Citations and Sources + +Linking to authoritative sources increases trust. + +- Cite primary sources +- Link to studies, documentation, official sources +- Avoid circular citations (sites citing each other) + +### Comprehensive Coverage + +AI prefers content that fully answers questions. + +- Cover related questions users might have +- Include definitions for technical terms +- Address common misconceptions + +## Measuring AEO Success + +### Monitor AI Mentions + +Track when AI assistants cite your content: + +- Search for your brand + "according to" +- Monitor traffic from AI platforms +- Check Perplexity, Bing Copilot responses + +### Track Zero-Click Queries + +If AI answers questions directly, traditional rankings matter less. + +### Featured Snippet Capture + +Featured snippets often become AI answers. Track which you own. + +## AEO vs SEO Balance + +AEO and SEO largely align—quality content serves both. Key differences: + +| Aspect | SEO Focus | AEO Focus | +| ------ | -------------- | ----------------------- | +| Goal | Rank on page 1 | Be THE answer | +| Format | Varies | Direct, structured | +| Length | Often longer | Concise + comprehensive | +| Links | Link building | Source citations | diff --git a/cmd/popola/.claude/skills/seo-aeo-best-practices/resources/eeat-principles.md b/cmd/popola/.claude/skills/seo-aeo-best-practices/resources/eeat-principles.md new file mode 100644 index 0000000..429ed06 --- /dev/null +++ b/cmd/popola/.claude/skills/seo-aeo-best-practices/resources/eeat-principles.md @@ -0,0 +1,146 @@ +# EEAT Principles + +Google's EEAT framework (Experience, Expertise, Authoritativeness, Trustworthiness) guides how content quality is evaluated. This applies to both SEO rankings and AI answer selection. + +## The Four Pillars + +### Experience + +First-hand or life experience with the topic. + +**Signals:** + +- Personal anecdotes and case studies +- "I tested this" content +- Real-world results and screenshots +- User-generated reviews + +**Implementation:** + +- Include author bios with relevant experience +- Add "About the Author" sections +- Feature customer testimonials +- Show real examples, not just theory + +### Expertise + +Knowledge and skill in the subject area. + +**Signals:** + +- Credentials and qualifications +- Depth of content coverage +- Technical accuracy +- Citations to authoritative sources + +**Implementation:** + +- Display author credentials +- Link to primary sources +- Cover topics comprehensively +- Keep content technically accurate and updated + +### Authoritativeness + +Recognition as a go-to source in the field. + +**Signals:** + +- Backlinks from respected sites +- Mentions in industry publications +- Social proof and follower counts +- Brand recognition + +**Implementation:** + +- Build thought leadership content +- Contribute to industry publications +- Maintain consistent publishing +- Develop recognizable brand voice + +### Trustworthiness + +Accuracy, transparency, and legitimacy. + +**Signals:** + +- Clear authorship and contact info +- Accurate, fact-checked content +- Secure website (HTTPS) +- Privacy policy and terms + +**Implementation:** + +- Display clear author attribution +- Include publication and update dates +- Provide contact information +- Use HTTPS and maintain security + +## Sanity Implementation + +```typescript +// Author schema with EEAT signals +defineType({ + name: "author", + type: "document", + fields: [ + defineField({ name: "name", type: "string" }), + defineField({ name: "role", type: "string" }), + defineField({ name: "bio", type: "text" }), + defineField({ + name: "credentials", + type: "array", + of: [{ type: "string" }], + }), + defineField({ name: "image", type: "image" }), + defineField({ + name: "socialLinks", + type: "array", + of: [ + { + type: "object", + fields: [ + defineField({ name: "platform", type: "string" }), + defineField({ name: "url", type: "url" }), + ], + }, + ], + }), + ], +}); + +// Content with EEAT metadata +defineType({ + name: "post", + fields: [ + defineField({ + name: "author", + type: "reference", + to: [{ type: "author" }], + }), + defineField({ name: "publishedAt", type: "datetime" }), + defineField({ name: "updatedAt", type: "datetime" }), + defineField({ + name: "reviewedBy", + type: "reference", + to: [{ type: "author" }], + description: "Expert reviewer for fact-checking", + }), + defineField({ + name: "sources", + type: "array", + of: [{ type: "url" }], + description: "Citations and references", + }), + ], +}); +``` + +## YMYL Considerations + +"Your Money or Your Life" topics (health, finance, legal, safety) require extra EEAT rigor: + +- Medical content reviewed by healthcare professionals +- Financial advice from certified experts +- Legal content reviewed by attorneys +- Clear disclaimers where appropriate diff --git a/cmd/popola/.claude/skills/seo-aeo-best-practices/resources/structured-data.md b/cmd/popola/.claude/skills/seo-aeo-best-practices/resources/structured-data.md new file mode 100644 index 0000000..0b65c65 --- /dev/null +++ b/cmd/popola/.claude/skills/seo-aeo-best-practices/resources/structured-data.md @@ -0,0 +1,150 @@ +# Structured Data (JSON-LD) + +Structured data helps search engines and AI understand your content. JSON-LD is the recommended format. + +## Why Structured Data Matters + +- **Rich snippets:** Enhanced search result appearance +- **Knowledge panels:** Featured information boxes +- **AI training:** Better content understanding +- **Voice search:** Answer selection for voice queries + +## Common Schema Types + +### Article / Blog Post + +```typescript +import { Article, WithContext } from "schema-dts"; + +const articleSchema: WithContext
      = { + "@context": "https://schema.org", + "@type": "Article", + headline: post.title, + description: post.excerpt, + image: post.image?.url, + datePublished: post.publishedAt, + dateModified: post.updatedAt, + author: { + "@type": "Person", + name: post.author.name, + url: post.author.url, + }, + publisher: { + "@type": "Organization", + name: "Your Company", + logo: { + "@type": "ImageObject", + url: "https://example.com/logo.png", + }, + }, +}; +``` + +### FAQ Page + +```typescript +import { FAQPage, WithContext } from "schema-dts"; + +const faqSchema: WithContext = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: faqs.map((faq) => ({ + "@type": "Question", + name: faq.question, + acceptedAnswer: { + "@type": "Answer", + text: faq.answer, // Plain text, use pt::text() in GROQ + }, + })), +}; +``` + +### Organization + +```typescript +import { Organization, WithContext } from "schema-dts"; + +const orgSchema: WithContext = { + "@context": "https://schema.org", + "@type": "Organization", + name: "Your Company", + url: "https://example.com", + logo: "https://example.com/logo.png", + sameAs: [ + "https://twitter.com/company", + "https://linkedin.com/company/company", + ], + contactPoint: { + "@type": "ContactPoint", + telephone: "+1-555-555-5555", + contactType: "customer service", + }, +}; +``` + +### Product + +```typescript +import { Product, WithContext } from "schema-dts"; + +const productSchema: WithContext = { + "@context": "https://schema.org", + "@type": "Product", + name: product.name, + description: product.description, + image: product.images, + offers: { + "@type": "Offer", + price: product.price, + priceCurrency: "USD", + availability: "https://schema.org/InStock", + }, + aggregateRating: product.rating + ? { + "@type": "AggregateRating", + ratingValue: product.rating.average, + reviewCount: product.rating.count, + } + : undefined, +}; +``` + +## Implementation in Next.js + +```typescript +// Component to render JSON-LD +function JsonLd({ data }: { data: WithContext }) { + return ( +