Files
tf_provider/TOOLS/docs-generator/internal/writers/writers.go
T
2026-07-17 09:51:57 +04:00

990 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package writers — генерация Markdown-документации для ресурсов.
package writers
import (
"bytes"
"encoding/json"
"fmt"
"html"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"docs-generator/internal/types"
)
// CloudOutputByServiceID глобальный кэш снепшотов облачных выходов.
var CloudOutputByServiceID = map[int]types.CloudOutputSnapshot{}
// ResourceDocs генерирует полный набор .md файлов для одного сервиса.
func ResourceDocs(docsDir string, spec types.ServiceSpec, version string, apiEndpoint string) {
base := spec.Name
nav := fmt.Sprintf("[Manual](%s.md) | [Create params](%s_params_create.md) | [Modify params](%s_params_modify.md) | [Output params](%s_outputs.md) | [Operations](%s_ops.md) | [Example](%s_example.md)", base, base, base, base, base, base)
WriteFile(filepath.Join(docsDir, base+".md"), buildManualPage(spec, nav))
WriteFile(filepath.Join(docsDir, base+"_example.md"), buildExamplePage(spec, nav, version, apiEndpoint))
WriteFile(filepath.Join(docsDir, base+"_params_create.md"), buildCreateParamsPage(spec, nav))
WriteFile(filepath.Join(docsDir, base+"_params_modify.md"), buildModifyParamsPage(spec, nav))
WriteFile(filepath.Join(docsDir, base+"_outputs.md"), buildOutputsPage(spec, nav))
WriteFile(filepath.Join(docsDir, base+"_ops.md"), buildOpsPage(spec, nav, docsDir))
WriteFile(filepath.Join(docsDir, base+"_params.md"), buildParamsLandingPage(spec, nav))
for _, srName := range CollectSubresources(spec) {
srBase := fmt.Sprintf("%s_%s", base, Slug(srName))
srNav := fmt.Sprintf("[%s (основной)](%s.md) | [Operations](%s_ops.md) | [%s](%s.md) | [Example](%s_example.md)",
spec.ServiceDisplayName, base, base, Capitalize(srName), srBase, srBase)
WriteFile(filepath.Join(docsDir, srBase+".md"), buildSubresourcePage(spec, srName, srNav))
WriteFile(filepath.Join(docsDir, srBase+"_example.md"), buildSubresourceExamplePage(spec, srName, srNav, version, apiEndpoint))
}
}
// WriteFile пишет контент в файл.
func WriteFile(path, content string) {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: cannot write %s: %v\n", path, err)
os.Exit(1)
}
}
// IndexMD генерирует index.md.
func IndexMD(docsDir string, specs []types.ServiceSpec) {
var b bytes.Buffer
b.WriteString("# Ресурсы провайдера\n\n")
b.WriteString("| ID | Ресурс | Описание |\n")
b.WriteString("|-----|--------|----------|\n")
for _, spec := range specs {
resName := "nubes_" + spec.Name
link := fmt.Sprintf("[%s](%s.md)", resName, spec.Name)
descr := strings.ReplaceAll(spec.ServiceDisplayName, "|", "\\|")
b.WriteString(fmt.Sprintf("| %d | %s | %s |\n", spec.ServiceID, link, descr))
}
outPath := filepath.Join(docsDir, "index.md")
if err := os.WriteFile(outPath, b.Bytes(), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to write index.md: %v\n", err)
}
}
// CollectSubresources возвращает уникальные subresource-имена.
func CollectSubresources(spec types.ServiceSpec) []string {
seen := map[string]bool{}
var out []string
for _, op := range spec.Operations {
if op.Kind == "subresource" && op.Subresource != "" {
if !seen[op.Subresource] {
seen[op.Subresource] = true
out = append(out, op.Subresource)
}
}
}
return out
}
// FindParams находит параметры операции по action.
func FindParams(ops []types.OperationSpec, action string) []types.ParamSpec {
for _, op := range ops {
if op.Kind == "instance" && op.Action == action {
return op.Params
}
}
return nil
}
// SplitParams делит параметры на обязательные и со значением по умолчанию.
func SplitParams(params []types.ParamSpec) ([]types.ParamSpec, []types.ParamSpec) {
required := []types.ParamSpec{}
defaults := []types.ParamSpec{}
for _, p := range params {
if hasDefault(p.Default) {
defaults = append(defaults, p)
continue
}
required = append(required, p)
}
return required, defaults
}
// FindSubresourceParams находит параметры subresource-операции.
func FindSubresourceParams(ops []types.OperationSpec, srName, action string) []types.ParamSpec {
for _, op := range ops {
if op.Kind == "subresource" && op.Subresource == srName && op.Action == action {
return op.Params
}
}
return nil
}
// ===== internal helpers =====
func buildHeader(spec types.ServiceSpec, nav string) string {
name := spec.ServiceDisplayName
if name == "" {
name = spec.Name
}
return fmt.Sprintf("# Resource nubes_%s\n\nService ID: `%d`\n\nService Name: %s\n\n%s\n\n", spec.Name, spec.ServiceID, name, nav)
}
func buildManualPage(spec types.ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## MAN\n\n")
man := strings.TrimSpace(spec.ServiceMan)
if man == "" {
man = "Manual not available."
}
b.WriteString("<div class=\"man-content\">\n\n")
b.WriteString(htmlToMarkdown(man))
b.WriteString("\n\n</div>\n")
return b.String()
}
// htmlToMarkdown converts raw HTML to Markdown.
func htmlToMarkdown(s string) string {
// <br/> or <br> → newline
s = regexp.MustCompile(`<br\s*/?>`).ReplaceAllString(s, "\n")
// <h1>...</h1> → # ...
s = regexp.MustCompile(`<h1>(.*?)</h1>`).ReplaceAllString(s, "# $1")
// <h2>...</h2> → ## ...
s = regexp.MustCompile(`<h2>(.*?)</h2>`).ReplaceAllString(s, "## $1")
// <h3>...</h3> → ### ...
s = regexp.MustCompile(`<h3>(.*?)</h3>`).ReplaceAllString(s, "### $1")
// <strong> or <b> → **...**
s = regexp.MustCompile(`<(?:strong|b)>(.*?)</(?:strong|b)>`).ReplaceAllString(s, "**$1**")
// <em> or <i> → *...*
s = regexp.MustCompile(`<(?:em|i)>(.*?)</(?:em|i)>`).ReplaceAllString(s, "*$1*")
// <code>...</code> → `...`
s = regexp.MustCompile(`<code>(.*?)</code>`).ReplaceAllString(s, "`$1`")
// <a href="...">...</a> → [...](...)
s = regexp.MustCompile(`<a\s+href="(.*?)">(.*?)</a>`).ReplaceAllString(s, "[$2]($1)")
// <ul> / </ul> — remove
s = regexp.MustCompile(`</?ul>`).ReplaceAllString(s, "")
// <li>...</li> → - ...
s = regexp.MustCompile(`<li>(.*?)</li>`).ReplaceAllString(s, "- $1")
// <ol> / </ol> — remove
s = regexp.MustCompile(`</?ol>`).ReplaceAllString(s, "")
// <p> / </p> — remove
s = regexp.MustCompile(`</?p>`).ReplaceAllString(s, "")
// <pre> / </pre> — remove (content stays)
s = regexp.MustCompile(`</?pre>`).ReplaceAllString(s, "")
// <hr> or <hr/> → ---
s = regexp.MustCompile(`<hr\s*/?>`).ReplaceAllString(s, "\n---\n")
// Strip remaining HTML tags
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
// Decode HTML entities
s = html.UnescapeString(s)
// Collapse multiple blank lines
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
func buildExamplePage(spec types.ServiceSpec, nav, version string, apiEndpoint string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_main.tf`)\n\n", spec.Name))
b.WriteString("```hcl\n")
b.WriteString(exampleBlock(spec, version, apiEndpoint))
b.WriteString("```\n\n")
b.WriteString("## Outputs usage\n\n")
b.WriteString("```hcl\n")
b.WriteString("# Выходные параметры можно использовать как\n")
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params_flat[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out_flat[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_secrets[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_url\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_user_path\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_fields\n", spec.Name))
b.WriteString("```\n")
return b.String()
}
func exampleBlock(spec types.ServiceSpec, version string, apiEndpoint string) string {
createParams := FindParams(spec.Operations, "create")
requiredParams, defaultParams := SplitParams(createParams)
var b strings.Builder
b.WriteString("terraform {\n")
b.WriteString(" required_providers {\n")
b.WriteString(" nubes = {\n")
b.WriteString(" source = \"registry.kube5s.ru/nubes-test/nubes\"\n")
b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version))
b.WriteString(" }\n")
b.WriteString(" }\n")
b.WriteString("}\n\n")
b.WriteString("# Токен доступа api_token к Nubes API — замените на реальный.\n")
b.WriteString("provider \"nubes\" {\n")
b.WriteString(" api_token = \"token_***\"\n")
b.WriteString(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint))
b.WriteString("}\n")
b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name))
for _, p := range requiredParams {
b.WriteString(formatParamOrBlock(p, " ", true))
}
if len(defaultParams) > 0 {
b.WriteString("\n # Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте\n")
for _, p := range defaultParams {
b.WriteString(formatParamOrBlock(p, " ", false))
}
}
b.WriteString("}\n")
return b.String()
}
func buildCreateParamsPage(spec types.ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Create params\n\n")
createParams := FindParams(spec.Operations, "create")
requiredParams, defaultParams := SplitParams(createParams)
b.WriteString("**Обязательные параметры (вводимые пользователем)**\n\n")
b.WriteString(renderParamTable(requiredParams, true, true))
for _, p := range requiredParams {
b.WriteString(renderNestedParams(p))
}
b.WriteString("\n**Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте**\n")
b.WriteString(renderParamTable(defaultParams, false, false))
for _, p := range defaultParams {
b.WriteString(renderNestedParams(p))
}
lifecycle := spec.Lifecycle
if lifecycle.SuspendOnDestroyDefault || lifecycle.AdoptExistingOnCreateDefault {
b.WriteString("\n<div class=\"lifecycle-note\">\n")
b.WriteString("<strong>Параметры поведения (кратко)</strong><br/>\n")
b.WriteString(fmt.Sprintf("По умолчанию: `suspend_on_destroy = %s`, `adopt_existing_on_create = %s`.<br/>\n", formatBoolTitle(lifecycle.SuspendOnDestroyDefault), formatBoolTitle(lifecycle.AdoptExistingOnCreateDefault)))
b.WriteString("При `terraform destroy` или удалении ресурса из манифеста:<br/>\n")
b.WriteString("- `suspend_on_destroy=true` — инстанс переводится в `Suspend` (не удаляется).<br/>\n")
b.WriteString("- `suspend_on_destroy=false` — Terraform удаляет ресурс только из state.<br/>\n")
b.WriteString("При `apply` флаг `adopt_existing_on_create` работает как авто-`import`:<br/>\n")
b.WriteString("- `false` — если ресурс уже есть, будет ошибка.<br/>\n")
b.WriteString("- `true` — Terraform может взять существующий инстанс под управление (`running` → adopt, `suspended` → resume+adopt при совпадении параметров).<br/>\n")
b.WriteString("Важно: один инстанс должен быть только в одном state. Иначе получите конфликт управления.\n")
b.WriteString("</div>\n")
}
return b.String()
}
func buildModifyParamsPage(spec types.ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Modify params\n\n")
modifyParams := FindParams(spec.Operations, "modify")
b.WriteString(renderModifyTable(modifyParams))
return b.String()
}
func buildOutputsPage(spec types.ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Output params\n\n")
if len(spec.Outputs.Params) == 0 {
b.WriteString("None.\n")
return b.String()
}
for _, p := range spec.Outputs.Params {
desc := outputDescription(p.Code)
if desc != "" {
b.WriteString(fmt.Sprintf("- `%s` (%s) — %s\n", p.Code, p.Type, desc))
} else {
b.WriteString(fmt.Sprintf("- `%s` (%s)\n", p.Code, p.Type))
}
}
snapshot, ok := CloudOutputByServiceID[spec.ServiceID]
if !ok {
return b.String()
}
hasOut := len(snapshot.OutPaths) > 0
hasVault := len(snapshot.VaultFields) > 0
if !hasOut && !hasVault {
return b.String()
}
b.WriteString("\n## Реальные поля из облака (running/suspended snapshot)\n\n")
b.WriteString("Поля ниже получены из реальных инстансов этого сервиса в облаке.\n")
b.WriteString("Используйте их как готовые ключи для `state_out_flat` и `vault_secrets`.\n\n")
if hasOut {
b.WriteString("### `state_out_flat` ключи\n\n")
for _, key := range snapshot.OutPaths {
if strings.TrimSpace(key) == "" {
continue
}
b.WriteString(fmt.Sprintf("- `state_out_flat[\"%s\"]`\n", key))
}
b.WriteString("\n")
}
if hasVault {
b.WriteString("### `vault_secrets` ключи\n\n")
for _, key := range snapshot.VaultFields {
if strings.TrimSpace(key) == "" {
continue
}
b.WriteString(fmt.Sprintf("- `vault_secrets[\"%s\"]`\n", key))
}
b.WriteString("\n")
}
if spec.ServiceID == 90 && containsString(snapshot.OutPaths, "internalConnect.master") && containsString(snapshot.VaultFields, "adminUser") && containsString(snapshot.VaultFields, "adminPass") {
b.WriteString("### Пример для связки с Lucee/NodeJS\n\n")
b.WriteString("```hcl\n")
b.WriteString("testds_connectionString = \"jdbc:postgresql://${nubes_postgres.db2.state_out_flat[\"internalConnect.master\"]}:5432/postgres?sslmode=require\"\n")
b.WriteString("testds_username = nubes_postgres.db2.vault_secrets[\"adminUser\"]\n")
b.WriteString("testds_password = nubes_postgres.db2.vault_secrets[\"adminPass\"]\n")
b.WriteString("```\n")
}
return b.String()
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func buildOpsPage(spec types.ServiceSpec, nav, docsDir string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Operations\n\n")
for _, op := range spec.Operations {
name := op.Name
if name == "" {
name = op.Action
}
descr := strings.TrimSpace(stripHTML(op.Man))
if descr != "" {
descr = ": " + descr
}
if op.Kind == "subresource" {
descrStr := strings.TrimPrefix(descr, ": ")
if op.Subresource != "" {
subFile := fmt.Sprintf("%s_%s.md", spec.Name, Slug(op.Subresource))
b.WriteString(fmt.Sprintf("- `%s` — %s. См. [nubes_%s_%s](%s)\n", name, descrStr, spec.Name, Slug(op.Subresource), subFile))
} else {
if descrStr != "" {
b.WriteString(fmt.Sprintf("\n#### `%s` — %s\n\n", name, descrStr))
} else {
b.WriteString(fmt.Sprintf("\n#### `%s`\n\n", name))
}
if len(op.Params) > 0 {
b.WriteString(renderParamTable(op.Params, true, true))
b.WriteString("\n")
}
}
continue
}
switch op.Action {
case "create":
b.WriteString(fmt.Sprintf("- `%s` — Создание кластера. Параметры: [%s params](%s_params_create.md)\n", name, "Create", spec.Name))
case "modify":
b.WriteString(fmt.Sprintf("- `%s` — Изменение кластера. Параметры: [%s params](%s_params_modify.md)\n", name, "Modify", spec.Name))
default:
if descr != "" {
b.WriteString(fmt.Sprintf("- `%s` — %s\n", name, strings.TrimPrefix(descr, ": ")))
} else {
b.WriteString(fmt.Sprintf("- `%s`\n", name))
}
}
}
return b.String()
}
func buildParamsLandingPage(spec types.ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Parameters\n\n")
b.WriteString(fmt.Sprintf("- [Create params](%s_params_create.md)\n", spec.Name))
b.WriteString(fmt.Sprintf("- [Modify params](%s_params_modify.md)\n", spec.Name))
return b.String()
}
func buildSubresourcePage(spec types.ServiceSpec, srName string, nav string) string {
var b strings.Builder
srResourceName := fmt.Sprintf("nubes_%s_%s", spec.Name, Slug(srName))
b.WriteString(fmt.Sprintf("# Resource %s\n\n", srResourceName))
b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID))
b.WriteString(nav + "\n\n")
createParams := FindSubresourceParams(spec.Operations, srName, "create")
b.WriteString("## Create params\n\n")
if len(createParams) > 0 {
req, def := SplitParams(createParams)
b.WriteString("**Обязательные параметры**\n\n")
b.WriteString(renderParamTable(req, true, true))
if len(def) > 0 {
b.WriteString("\n**Параметры со значением по умолчанию**\n\n")
b.WriteString(renderParamTable(def, false, false))
}
} else {
b.WriteString("None.\n")
}
modifyParams := FindSubresourceParams(spec.Operations, srName, "modify")
if len(modifyParams) > 0 {
b.WriteString("\n## Modify params\n\n")
b.WriteString(renderModifyTable(modifyParams))
}
deleteParams := FindSubresourceParams(spec.Operations, srName, "delete")
b.WriteString("\n## Delete params\n\n")
if len(deleteParams) > 0 {
b.WriteString(renderParamTable(deleteParams, true, true))
} else {
b.WriteString("None.\n")
}
return b.String()
}
func buildSubresourceExamplePage(spec types.ServiceSpec, srName string, nav string, version string, apiEndpoint string) string {
var b strings.Builder
srResourceName := fmt.Sprintf("nubes_%s_%s", spec.Name, Slug(srName))
b.WriteString(fmt.Sprintf("# Resource %s — Example\n\n", srResourceName))
b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID))
b.WriteString(nav + "\n\n")
b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_%s_main.tf`)\n\n", spec.Name, Slug(srName)))
b.WriteString("```hcl\n")
b.WriteString("terraform {\n")
b.WriteString(" required_providers {\n")
b.WriteString(" nubes = {\n")
b.WriteString(" source = \"registry.kube5s.ru/nubes-test/nubes\"\n")
b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version))
b.WriteString(" }\n")
b.WriteString(" }\n")
b.WriteString("}\n\n")
b.WriteString("provider \"nubes\" {\n")
b.WriteString(" api_token = \"token_***\"\n")
b.WriteString(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint))
b.WriteString("}\n\n")
b.WriteString(fmt.Sprintf("# Родительский ресурс — nubes_%s должен быть создан заранее\n", spec.Name))
b.WriteString(fmt.Sprintf("# resource \"nubes_%s\" \"baza\" { ... }\n\n", spec.Name))
b.WriteString(fmt.Sprintf("resource \"%s\" \"example\" {\n", srResourceName))
b.WriteString(fmt.Sprintf(" %s_id = nubes_%s.baza.id\n", spec.Name, spec.Name))
createParams := FindSubresourceParams(spec.Operations, srName, "create")
req, def := SplitParams(createParams)
for _, p := range req {
b.WriteString(formatParamLine(p, " ", true))
}
if len(def) > 0 {
b.WriteString("\n # Параметры со значением по умолчанию\n")
for _, p := range def {
b.WriteString(formatParamLine(p, " ", false))
}
}
b.WriteString("}\n")
b.WriteString("```\n")
return b.String()
}
// ===== formatting helpers =====
func renderParamTable(params []types.ParamSpec, requiredTable, noDefault bool) string {
if len(params) == 0 {
return "None.\n"
}
var b strings.Builder
tableClass := "resource-table resource-table-compact"
if requiredTable {
tableClass += " resource-table-required"
}
b.WriteString(fmt.Sprintf("<table class=\"%s\">\n", tableClass))
if noDefault {
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
} else {
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Default</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
}
for _, p := range params {
b.WriteString("<tr>")
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(formatID(p.ID))))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatParamCode(p.Code)))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatTypeCell(p)))
if !noDefault {
b.WriteString(fmt.Sprintf("<td>%s</td>", defaultCell(p.Default)))
}
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(pickTextTable(p))))
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(collectConstraints(p))))
b.WriteString("</tr>\n")
}
b.WriteString("</tbody></table>\n")
return b.String()
}
func renderModifyTable(params []types.ParamSpec) string {
if len(params) == 0 {
return "None.\n"
}
var b strings.Builder
b.WriteString("<table class=\"resource-table resource-table-compact\">\n")
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
for _, p := range params {
b.WriteString("<tr>")
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(formatID(p.ID))))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatParamCode(p.Code)))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatTypeCell(p)))
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(pickTextTable(p))))
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(collectConstraints(p))))
b.WriteString("</tr>\n")
}
b.WriteString("</tbody></table>\n")
return b.String()
}
func formatParamLine(p types.ParamSpec, indent string, requiredOnly bool) string {
if requiredOnly && !p.Required {
return ""
}
value := sampleValue(p)
comment := strings.TrimSpace(stripHTML(p.Descr))
if comment == "" {
comment = strings.TrimSpace(stripHTML(p.Man))
}
paramCode := ToSnake(p.Code)
if comment != "" {
return fmt.Sprintf("%s%s = %s # %s\n", indent, paramCode, value, comment)
}
return fmt.Sprintf("%s%s = %s\n", indent, paramCode, value)
}
// formatParamOrBlock форматирует параметр или вложенный HCL-блок (map-fixed).
func formatParamOrBlock(p types.ParamSpec, indent string, requiredOnly bool) string {
if requiredOnly && !p.Required {
return ""
}
// Для map-fixed генерируем вложенный HCL-блок
if p.HasSubParams && len(p.SubParams) > 0 && !p.IsJson {
paramCode := ToSnake(p.Code)
var b strings.Builder
b.WriteString(fmt.Sprintf("%s%s {\n", indent, paramCode))
for _, sp := range p.SubParams {
spVal := sampleValue(sp)
spCode := ToSnake(sp.Code)
spComment := strings.TrimSpace(stripHTML(sp.Descr))
if spComment == "" {
spComment = strings.TrimSpace(stripHTML(sp.Man))
}
if spComment != "" {
b.WriteString(fmt.Sprintf("%s %s = %s # %s\n", indent, spCode, spVal, spComment))
} else {
b.WriteString(fmt.Sprintf("%s %s = %s\n", indent, spCode, spVal))
}
}
b.WriteString(fmt.Sprintf("%s}\n", indent))
return b.String()
}
// Для array-map-fixed генерируем dynamic блок
if p.HasSubParams && len(p.SubParams) > 0 && p.IsJson {
paramCode := ToSnake(p.Code)
var b strings.Builder
b.WriteString(fmt.Sprintf("%s%s {\n", indent, paramCode))
b.WriteString(fmt.Sprintf("%s # Каждый элемент массива — объект с полями:\n", indent))
for _, sp := range p.SubParams {
spVal := sampleValue(sp)
spCode := ToSnake(sp.Code)
spComment := strings.TrimSpace(stripHTML(sp.Descr))
if spComment == "" {
spComment = strings.TrimSpace(stripHTML(sp.Man))
}
if spComment != "" {
b.WriteString(fmt.Sprintf("%s %s = %s # %s\n", indent, spCode, spVal, spComment))
} else {
b.WriteString(fmt.Sprintf("%s %s = %s\n", indent, spCode, spVal))
}
}
b.WriteString(fmt.Sprintf("%s}\n", indent))
return b.String()
}
return formatParamLine(p, indent, requiredOnly)
}
func sampleValue(p types.ParamSpec) string {
if hasDefault(p.Default) {
return formatLiteral(p.Default)
}
if len(p.ValueList) > 0 {
return formatLiteral(p.ValueList[0])
}
dtype := strings.ToLower(firstType(p))
switch {
case strings.Contains(dtype, "bool"):
return "false"
case strings.Contains(dtype, "int") || strings.Contains(dtype, "number"):
return "1"
case strings.Contains(dtype, "json"):
return "{}"
}
return "\"TODO\""
}
func formatLiteral(value interface{}) string {
switch v := value.(type) {
case bool:
if v {
return "true"
}
return "false"
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case string:
if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") {
return v
}
if strings.HasPrefix(v, "\"") && strings.HasSuffix(v, "\"") {
return v
}
if looksLikeNumber(v) || v == "true" || v == "false" {
return v
}
return fmt.Sprintf("\"%s\"", v)
default:
return fmt.Sprintf("\"%v\"", v)
}
}
func looksLikeNumber(value string) bool {
if value == "" {
return false
}
if value[0] == '-' {
value = value[1:]
}
for _, ch := range value {
if ch == '.' {
continue
}
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func formatID(id int) string {
if id == 0 {
return ""
}
return strconv.Itoa(id)
}
func formatTypeCell(p types.ParamSpec) string {
dtype := firstType(p)
if dtype == "" {
return ""
}
return "<code>" + escapeText(dtype) + "</code>"
}
func firstType(p types.ParamSpec) string {
if p.DataType != "" {
return normalizeType(p.DataType)
}
return normalizeType(p.Type)
}
func normalizeType(dtype string) string {
cleaned := strings.ReplaceAll(dtype, "&gt;", ">")
if idx := strings.Index(cleaned, ">"); idx >= 0 {
cleaned = cleaned[:idx]
}
return strings.TrimSpace(cleaned)
}
func defaultCell(value interface{}) string {
if !hasDefault(value) {
return ""
}
if s, ok := value.(string); ok {
return "<code>" + escapeText(s) + "</code>"
}
return "<code>" + escapeText(formatLiteral(value)) + "</code>"
}
func pickTextTable(p types.ParamSpec) string {
if p.Man != "" {
return p.Man
}
return p.Descr
}
func hasDefault(value interface{}) bool {
if value == nil {
return false
}
if s, ok := value.(string); ok {
return strings.TrimSpace(s) != ""
}
return true
}
func outputDescription(code string) string {
switch code {
case "state_params":
return "параметры, отправленные в API при создании/изменении"
case "state_out":
return "ответ API с результатами/выходными значениями"
case "state_params_flat":
return "параметры в плоском виде (ключи с путями)"
case "state_out_flat":
return "ответы в плоском виде (ключи с путями)"
case "vault_secrets":
return "секреты, записанные в Vault для ресурса"
case "vault_url":
return "адрес Vault для ресурса"
case "vault_user_path":
return "путь пользователя в Vault"
case "vault_fields":
return "список ключей доступных секретов"
default:
return ""
}
}
func formatBoolTitle(value bool) string {
if value {
return "True"
}
return "False"
}
func collectConstraints(p types.ParamSpec) string {
parts := []string{}
if p.MinValue != nil {
parts = append(parts, fmt.Sprintf("minvalue=%d", *p.MinValue))
}
if p.MaxValue != nil {
parts = append(parts, fmt.Sprintf("maxvalue=%d", *p.MaxValue))
}
if p.Regex != "" {
parts = append(parts, fmt.Sprintf("regex=%s", p.Regex))
}
if len(p.ValueList) > 0 {
parts = append(parts, fmt.Sprintf("value_list=%s", strings.Join(p.ValueList, ", ")))
}
if p.Func != "" {
parts = append(parts, fmt.Sprintf("func=%s", p.Func))
}
if p.RefSvcID != nil {
parts = append(parts, fmt.Sprintf("ref_svc_id=%d", *p.RefSvcID))
}
if p.Unique != "" {
parts = append(parts, fmt.Sprintf("unique_scope=%s", p.Unique))
}
if p.MaxLength != nil {
parts = append(parts, fmt.Sprintf("maxlength=%d", *p.MaxLength))
}
if p.MinLength != nil {
parts = append(parts, fmt.Sprintf("minlength=%d", *p.MinLength))
}
return strings.Join(parts, "; ")
}
func escapeText(value string) string {
if value == "" {
return ""
}
text := html.EscapeString(value)
text = strings.ReplaceAll(text, "&lt;br/&gt;", "<br/>")
text = strings.ReplaceAll(text, "&lt;br /&gt;", "<br/>")
text = strings.ReplaceAll(text, "&lt;br&gt;", "<br/>")
return text
}
func formatParamCode(code string) string {
return formatCode(ToSnake(code))
}
func formatCode(code string) string {
if code == "" {
return ""
}
if len(code) <= 40 {
return "<strong><code class=\"code-no-wrap\">" + code + "</code></strong>"
}
out := []string{}
prev := rune(0)
for _, ch := range code {
if ch == '_' {
out = append(out, "_<wbr>")
prev = ch
continue
}
if prev != 0 && (unicodeIsLower(prev) || unicodeIsDigit(prev)) && unicodeIsUpper(ch) {
out = append(out, "<wbr>")
}
out = append(out, string(ch))
prev = ch
}
return "<strong><code class=\"code-wrap\">" + strings.Join(out, "") + "</code></strong>"
}
// ToSnake конвертирует CamelCase → snake_case.
func ToSnake(value string) string {
if value == "" {
return ""
}
var out []rune
lastUnderscore := false
prevLowerOrDigit := false
for _, r := range value {
if r >= 'A' && r <= 'Z' {
if prevLowerOrDigit && !lastUnderscore {
out = append(out, '_')
}
out = append(out, r+'a'-'A')
lastUnderscore = false
prevLowerOrDigit = false
continue
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
out = append(out, r)
lastUnderscore = false
prevLowerOrDigit = true
continue
}
if !lastUnderscore && len(out) > 0 {
out = append(out, '_')
lastUnderscore = true
}
prevLowerOrDigit = false
}
return strings.Trim(string(out), "_")
}
func unicodeIsLower(r rune) bool { return r >= 'a' && r <= 'z' }
func unicodeIsUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
func unicodeIsDigit(r rune) bool { return r >= '0' && r <= '9' }
func stripHTML(value string) string {
if value == "" {
return ""
}
re := regexp.MustCompile("<[^>]+>")
cleaned := re.ReplaceAllString(value, " ")
cleaned = strings.Join(strings.Fields(cleaned), " ")
return html.UnescapeString(cleaned)
}
// Slug нормализует строку для использования в URL/имени файла.
func Slug(value string) string {
out := []rune{}
for _, ch := range value {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' {
out = append(out, ch)
} else if ch >= 'A' && ch <= 'Z' {
out = append(out, ch+'a'-'A')
} else {
out = append(out, '_')
}
}
return strings.Trim(string(out), "_")
}
// Capitalize делает первую букву заглавной.
func Capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// LoadCloudOutputSnapshot загружает снепшот облачных выходов.
func LoadCloudOutputSnapshot(root string) map[int]types.CloudOutputSnapshot {
out := map[int]types.CloudOutputSnapshot{}
base := filepath.Join(root, "docs", "70_api")
entries, err := os.ReadDir(base)
if err != nil {
return out
}
latestDir := ""
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, "output_inventory_snapshot_") {
if name > latestDir {
latestDir = name
}
}
}
if latestDir == "" {
return out
}
path := filepath.Join(base, latestDir, "running_suspended_output_fields_for_docs.json")
b, err := os.ReadFile(path)
if err != nil {
return out
}
var items []types.CloudOutputSnapshot
if err := json.Unmarshal(b, &items); err != nil {
return out
}
for _, item := range items {
out[item.ServiceID] = item
}
return out
}
// renderNestedParams рендерит вложенные параметры (map-fixed/array-map-fixed).
func renderNestedParams(p types.ParamSpec) string {
if !p.HasSubParams || len(p.SubParams) == 0 {
return ""
}
var b strings.Builder
label := p.Code
if p.IsJson {
label += " (array-map-fixed) — элемент"
} else {
label += " (map-fixed)"
}
b.WriteString(fmt.Sprintf("\n### %s\n\n", label))
b.WriteString("<table class=\"resource-table resource-table-compact resource-table-nested\">\n")
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Required</th><th>Default</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
for _, sp := range p.SubParams {
req := "no"
if sp.Required {
req = "**yes**"
}
b.WriteString("<tr>")
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(formatID(sp.ID))))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatParamCode(sp.Code)))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatTypeCell(sp)))
b.WriteString(fmt.Sprintf("<td>%s</td>", req))
b.WriteString(fmt.Sprintf("<td>%s</td>", defaultCell(sp.Default)))
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(pickTextTable(sp))))
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(collectConstraints(sp))))
b.WriteString("</tr>\n")
}
b.WriteString("</tbody></table>\n")
return b.String()
}