github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/hack/docgen/api/main.go (about) 1 /* 2 Copyright (C) 2022-2023 ApeCloud Co., Ltd 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 /* 18 Fork from https://github.com/ahmetb/gen-crd-api-reference-docs 19 */ 20 21 package main 22 23 import ( 24 "bytes" 25 "encoding/json" 26 "flag" 27 "fmt" 28 "html/template" 29 "io" 30 "net/http" 31 "os" 32 "path/filepath" 33 "reflect" 34 "regexp" 35 "sort" 36 "strconv" 37 "strings" 38 texttemplate "text/template" 39 "time" 40 "unicode" 41 42 "golang.org/x/text/cases" 43 "golang.org/x/text/language" 44 45 "github.com/pkg/errors" 46 "github.com/russross/blackfriday/v2" 47 "k8s.io/gengo/parser" 48 "k8s.io/gengo/types" 49 "k8s.io/klog" 50 ) 51 52 var ( 53 flConfig = flag.String("config", "", "path to config file") 54 flAPIDir = flag.String("api-dir", "", "api directory (or import path), point this to pkg/apis") 55 flTemplateDir = flag.String("template-dir", "template", "path to template/ dir") 56 57 flHTTPAddr = flag.String("http-addr", "", "start an HTTP server on specified addr to view the result (e.g. :8080)") 58 flOutFile = flag.String("out-file", "", "path to output file to save the result") 59 60 apiOrder = map[string]int{"cluster": 1, "backup": 2, "add-on": 3} 61 ) 62 63 const ( 64 docCommentForceIncludes = "// +gencrdrefdocs:force" 65 ) 66 67 type generatorConfig struct { 68 // HiddenMemberFields hides fields with specified names on all types. 69 HiddenMemberFields []string `json:"hideMemberFields"` 70 71 // HideTypePatterns hides types matching the specified patterns from the 72 // output. 73 HideTypePatterns []string `json:"hideTypePatterns"` 74 75 // ExternalPackages lists recognized external package references and how to 76 // link to them. 77 ExternalPackages []externalPackage `json:"externalPackages"` 78 79 // TypeDisplayNamePrefixOverrides is a mapping of how to override displayed 80 // name for types with certain prefixes with what value. 81 TypeDisplayNamePrefixOverrides map[string]string `json:"typeDisplayNamePrefixOverrides"` 82 83 // MarkdownDisabled controls markdown rendering for comment lines. 84 MarkdownDisabled bool `json:"markdownDisabled"` 85 } 86 87 type externalPackage struct { 88 TypeMatchPrefix string `json:"typeMatchPrefix"` 89 DocsURLTemplate string `json:"docsURLTemplate"` 90 } 91 92 type apiPackage struct { 93 apiGroup string 94 apiVersion string 95 GoPackages []*types.Package 96 Types []*types.Type // because multiple 'types.Package's can add types to an apiVersion 97 Constants []*types.Type 98 } 99 100 func (v *apiPackage) identifier() string { return fmt.Sprintf("%s/%s", v.apiGroup, v.apiVersion) } 101 102 func init() { 103 klog.InitFlags(nil) 104 err := flag.Set("alsologtostderr", "true") 105 if err != nil { 106 return 107 } 108 flag.Parse() 109 110 if *flConfig == "" { 111 panic("-config not specified") 112 } 113 if *flAPIDir == "" { 114 panic("-api-dir not specified") 115 } 116 if *flHTTPAddr == "" && *flOutFile == "" { 117 panic("-out-file or -http-addr must be specified") 118 } 119 if *flHTTPAddr != "" && *flOutFile != "" { 120 panic("only -out-file or -http-addr can be specified") 121 } 122 if err := resolveTemplateDir(*flTemplateDir); err != nil { 123 panic(err) 124 } 125 126 } 127 128 func resolveTemplateDir(dir string) error { 129 path, err := filepath.Abs(dir) 130 if err != nil { 131 return err 132 } 133 if fi, err := os.Stat(path); err != nil { 134 return errors.Wrapf(err, "cannot read the %s directory", path) 135 } else if !fi.IsDir() { 136 return errors.Errorf("%s path is not a directory", path) 137 } 138 return nil 139 } 140 141 func main() { 142 defer klog.Flush() 143 144 f, err := os.Open(*flConfig) 145 if err != nil { 146 klog.Fatalf("failed to open config file: %+v", err) 147 } 148 d := json.NewDecoder(f) 149 d.DisallowUnknownFields() 150 var config generatorConfig 151 if err := d.Decode(&config); err != nil { 152 klog.Fatalf("failed to parse config file: %+v", err) 153 } 154 155 klog.Infof("parsing go packages in directory %s", *flAPIDir) 156 pkgs, err := parseAPIPackages() 157 if err != nil { 158 klog.Fatal(err) 159 } 160 if len(pkgs) == 0 { 161 klog.Fatalf("no API packages found in %s", *flAPIDir) 162 } 163 164 apiPackages, err := combineAPIPackages(pkgs) 165 if err != nil { 166 klog.Fatal(err) 167 } 168 169 mkOutput := func() (string, error) { 170 var b bytes.Buffer 171 err := render(&b, apiPackages, config) 172 if err != nil { 173 return "", errors.Wrap(err, "failed to render the result") 174 } 175 176 // remove trailing whitespace from each html line for markdown renderers 177 s := regexp.MustCompile(`(?m)^\s+`).ReplaceAllString(b.String(), "") 178 return s, nil 179 } 180 181 if *flOutFile != "" { 182 dir := filepath.Dir(*flOutFile) 183 if err := os.MkdirAll(dir, 0755); err != nil { 184 klog.Fatalf("failed to create dir %s: %v", dir, err) 185 } 186 s, err := mkOutput() 187 if err != nil { 188 klog.Fatalf("failed: %+v", err) 189 } 190 if err := os.WriteFile(*flOutFile, []byte(s), 0644); err != nil { 191 klog.Fatalf("failed to write to out file: %v", err) 192 } 193 klog.Infof("written to %s", *flOutFile) 194 } 195 196 if *flHTTPAddr != "" { 197 h := func(w http.ResponseWriter, r *http.Request) { 198 now := time.Now() 199 defer func() { klog.Infof("request took %v", time.Since(now)) }() 200 s, err := mkOutput() 201 if err != nil { 202 _, _ = fmt.Fprintf(w, "error: %+v", err) 203 klog.Warningf("failed: %+v", err) 204 } 205 if _, err := fmt.Fprint(w, s); err != nil { 206 klog.Warningf("response write error: %v", err) 207 } 208 } 209 http.HandleFunc("/", h) 210 klog.Infof("server listening at %s", *flHTTPAddr) 211 klog.Fatal(http.ListenAndServe(*flHTTPAddr, nil)) 212 } 213 } 214 215 // groupName extracts the "//+groupName" meta-comment from the specified 216 // package's comments, or returns empty string if it cannot be found. 217 func groupName(pkg *types.Package) string { 218 m := types.ExtractCommentTags("+", pkg.Comments) 219 v := m["groupName"] 220 if len(v) == 1 { 221 return v[0] 222 } 223 return "" 224 } 225 226 func parseAPIPackages() ([]*types.Package, error) { 227 b := parser.New() 228 // the following will silently fail (turn on -v=4 to see logs) 229 if err := b.AddDirRecursive(*flAPIDir); err != nil { 230 return nil, err 231 } 232 scan, err := b.FindTypes() 233 if err != nil { 234 return nil, errors.Wrap(err, "failed to parse pkgs and types") 235 } 236 var pkgNames []string 237 for p := range scan { 238 pkg := scan[p] 239 klog.V(3).Infof("trying package=%v groupName=%s", p, groupName(pkg)) 240 241 // Do not pick up packages that are in vendor/ as API packages. (This 242 // happened in knative/eventing-sources/vendor/..., where a package 243 // matched the pattern, but it didn't have a compatible import path). 244 if isVendorPackage(pkg) { 245 klog.V(3).Infof("package=%v coming from vendor/, ignoring.", p) 246 continue 247 } 248 249 if groupName(pkg) != "" && len(pkg.Types) > 0 || containsString(pkg.DocComments, docCommentForceIncludes) { 250 klog.V(3).Infof("package=%v has groupName and has types", p) 251 pkgNames = append(pkgNames, p) 252 } 253 } 254 sort.Strings(pkgNames) 255 var pkgs []*types.Package 256 for _, p := range pkgNames { 257 klog.Infof("using package=%s", p) 258 pkgs = append(pkgs, scan[p]) 259 } 260 return pkgs, nil 261 } 262 263 func containsString(sl []string, str string) bool { 264 for _, s := range sl { 265 if str == s { 266 return true 267 } 268 } 269 return false 270 } 271 272 // combineAPIPackages groups the Go packages by the <apiGroup+apiVersion> they 273 // offer, and combines the types in them. 274 func combineAPIPackages(pkgs []*types.Package) ([]*apiPackage, error) { 275 pkgMap := make(map[string]*apiPackage) 276 var pkgIds []string 277 278 flattenTypes := func(typeMap map[string]*types.Type) []*types.Type { 279 typeList := make([]*types.Type, 0, len(typeMap)) 280 281 for _, t := range typeMap { 282 typeList = append(typeList, t) 283 } 284 285 return typeList 286 } 287 288 for _, pkg := range pkgs { 289 apiGroup, apiVersion, err := apiVersionForPackage(pkg) 290 if err != nil { 291 return nil, errors.Wrapf(err, "could not get apiVersion for package %s", pkg.Path) 292 } 293 294 id := fmt.Sprintf("%s/%s", apiGroup, apiVersion) 295 v, ok := pkgMap[id] 296 if !ok { 297 pkgMap[id] = &apiPackage{ 298 apiGroup: apiGroup, 299 apiVersion: apiVersion, 300 Types: flattenTypes(pkg.Types), 301 Constants: flattenTypes(pkg.Constants), 302 GoPackages: []*types.Package{pkg}, 303 } 304 pkgIds = append(pkgIds, id) 305 } else { 306 v.Types = append(v.Types, flattenTypes(pkg.Types)...) 307 v.Constants = append(v.Constants, flattenTypes(pkg.Constants)...) 308 v.GoPackages = append(v.GoPackages, pkg) 309 } 310 } 311 312 sort.Strings(pkgIds) 313 314 out := make([]*apiPackage, 0, len(pkgMap)) 315 for _, id := range pkgIds { 316 out = append(out, pkgMap[id]) 317 } 318 return out, nil 319 } 320 321 // isVendorPackage determines if package is coming from vendor/ dir. 322 func isVendorPackage(pkg *types.Package) bool { 323 vendorPattern := string(os.PathSeparator) + "vendor" + string(os.PathSeparator) 324 return strings.Contains(pkg.SourcePath, vendorPattern) 325 } 326 327 func findTypeReferences(pkgs []*apiPackage) map[*types.Type][]*types.Type { 328 m := make(map[*types.Type][]*types.Type) 329 for _, pkg := range pkgs { 330 for _, typ := range pkg.Types { 331 for _, member := range typ.Members { 332 t := member.Type 333 t = tryDereference(t) 334 m[t] = append(m[t], typ) 335 } 336 } 337 } 338 return m 339 } 340 341 func isExportedType(t *types.Type) bool { 342 return strings.Contains(strings.Join(t.SecondClosestCommentLines, "\n"), "+genclient") || strings.Contains(strings.Join(t.SecondClosestCommentLines, "\n"), "+kubebuilder:object:root=true") 343 } 344 345 func fieldName(m types.Member) string { 346 v := reflect.StructTag(m.Tags).Get("json") 347 v = strings.TrimSuffix(v, ",omitempty") 348 v = strings.TrimSuffix(v, ",inline") 349 if v != "" { 350 return v 351 } 352 return m.Name 353 } 354 355 func fieldEmbedded(m types.Member) bool { 356 return strings.Contains(reflect.StructTag(m.Tags).Get("json"), ",inline") 357 } 358 359 func isLocalType(t *types.Type, typePkgMap map[*types.Type]*apiPackage) bool { 360 t = tryDereference(t) 361 _, ok := typePkgMap[t] 362 return ok 363 } 364 365 func renderComments(s []string, markdown bool) string { 366 s = filterCommentTags(s) 367 doc := strings.Join(s, "\n") 368 369 if markdown { 370 doc = string(blackfriday.Run([]byte(doc))) 371 doc = strings.ReplaceAll(doc, "\n", string(template.HTML("<br />"))) 372 doc = strings.ReplaceAll(doc, "{", string(template.HTML("{"))) 373 doc = strings.ReplaceAll(doc, "}", string(template.HTML("}"))) 374 return doc 375 } 376 return nl2br(doc) 377 } 378 379 func safe(s string) template.HTML { return template.HTML(s) } 380 381 func toTitle(s string) string { return cases.Title(language.English).String(s) } 382 383 func nl2br(s string) string { 384 return strings.ReplaceAll(s, "\n\n", string(template.HTML("<br/><br/>"))) 385 } 386 387 func hiddenMember(m types.Member, c generatorConfig) bool { 388 for _, v := range c.HiddenMemberFields { 389 if m.Name == v { 390 return true 391 } 392 } 393 return false 394 } 395 396 func typeIdentifier(t *types.Type) string { 397 t = tryDereference(t) 398 return t.Name.String() // {PackagePath.Name} 399 } 400 401 // apiGroupForType looks up apiGroup for the given type 402 func apiGroupForType(t *types.Type, typePkgMap map[*types.Type]*apiPackage) string { 403 t = tryDereference(t) 404 405 v := typePkgMap[t] 406 if v == nil { 407 klog.Warningf("WARNING: cannot read apiVersion for %s from type=>pkg map", t.Name.String()) 408 return "<UNKNOWN_API_GROUP>" 409 } 410 411 return v.identifier() 412 } 413 414 // anchorIDForLocalType returns the #anchor string for the local type 415 func anchorIDForLocalType(t *types.Type, typePkgMap map[*types.Type]*apiPackage) string { 416 return fmt.Sprintf("%s.%s", apiGroupForType(t, typePkgMap), t.Name.Name) 417 } 418 419 // linkForType returns an anchor to the type if it can be generated. returns 420 // empty string if it is not a local type or unrecognized external type. 421 func linkForType(t *types.Type, c generatorConfig, typePkgMap map[*types.Type]*apiPackage) (string, error) { 422 t = tryDereference(t) // dereference kind=Pointer 423 424 if isLocalType(t, typePkgMap) { 425 return "#" + anchorIDForLocalType(t, typePkgMap), nil 426 } 427 428 var arrIndex = func(a []string, i int) string { 429 return a[(len(a)+i)%len(a)] 430 } 431 432 // types like k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta, 433 // k8s.io/api/core/v1.Container, k8s.io/api/autoscaling/v1.CrossVersionObjectReference, 434 // github.com/knative/build/pkg/apis/build/v1alpha1.BuildSpec 435 if t.Kind == types.Struct || t.Kind == types.Pointer || t.Kind == types.Interface || t.Kind == types.Alias { 436 id := typeIdentifier(t) // gives {{ImportPath.Identifier}} for type 437 segments := strings.Split(t.Name.Package, "/") // to parse [meta, v1] from "k8s.io/apimachinery/pkg/apis/meta/v1" 438 439 for _, v := range c.ExternalPackages { 440 r, err := regexp.Compile(v.TypeMatchPrefix) 441 if err != nil { 442 return "", errors.Wrapf(err, "pattern %q failed to compile", v.TypeMatchPrefix) 443 } 444 if r.MatchString(id) { 445 tpl, err := texttemplate.New("").Funcs(map[string]interface{}{ 446 "lower": strings.ToLower, 447 "arrIndex": arrIndex, 448 }).Parse(v.DocsURLTemplate) 449 if err != nil { 450 return "", errors.Wrap(err, "docs URL template failed to parse") 451 } 452 453 var b bytes.Buffer 454 if err := tpl. 455 Execute(&b, map[string]interface{}{ 456 "TypeIdentifier": t.Name.Name, 457 "PackagePath": t.Name.Package, 458 "PackageSegments": segments, 459 }); err != nil { 460 return "", errors.Wrap(err, "docs url template execution error") 461 } 462 return b.String(), nil 463 } 464 } 465 klog.Warningf("not found external link source for type %v", t.Name) 466 } 467 return "", nil 468 } 469 470 // tryDereference returns the underlying type when t is a pointer, map, or slice. 471 func tryDereference(t *types.Type) *types.Type { 472 for t.Elem != nil { 473 t = t.Elem 474 } 475 return t 476 } 477 478 // finalUnderlyingTypeOf walks the type hierarchy for t and returns 479 // its base type (i.e. the type that has no further underlying type). 480 func finalUnderlyingTypeOf(t *types.Type) *types.Type { 481 for { 482 if t.Underlying == nil { 483 return t 484 } 485 486 t = t.Underlying 487 } 488 } 489 490 func typeDisplayName(t *types.Type, c generatorConfig, typePkgMap map[*types.Type]*apiPackage) string { 491 s := typeIdentifier(t) 492 493 if isLocalType(t, typePkgMap) { 494 s = tryDereference(t).Name.Name 495 } 496 497 if t.Kind == types.Pointer { 498 s = strings.TrimLeft(s, "*") 499 } 500 501 switch t.Kind { 502 case types.Struct, 503 types.Interface, 504 types.Alias, 505 types.Pointer, 506 types.Slice, 507 types.Builtin: 508 // noop 509 case types.Map: 510 // return original name 511 return t.Name.Name 512 case types.DeclarationOf: 513 // For constants, we want to display the value 514 // rather than the name of the constant, since the 515 // value is what users will need to write into YAML 516 // specs. 517 if t.ConstValue != nil { 518 u := finalUnderlyingTypeOf(t) 519 // Quote string constants to make it clear to the documentation reader. 520 if u.Kind == types.Builtin && u.Name.Name == "string" { 521 return strconv.Quote(*t.ConstValue) 522 } 523 524 return *t.ConstValue 525 } 526 klog.Fatalf("type %s is a non-const declaration, which is unhandled", t.Name) 527 default: 528 klog.Fatalf("type %s has kind=%v which is unhandled", t.Name, t.Kind) 529 } 530 531 // substitute prefix, if registered 532 for prefix, replacement := range c.TypeDisplayNamePrefixOverrides { 533 if strings.HasPrefix(s, prefix) { 534 s = strings.Replace(s, prefix, replacement, 1) 535 } 536 } 537 538 if t.Kind == types.Slice { 539 s = "[]" + s 540 } 541 542 return s 543 } 544 545 func hideType(t *types.Type, c generatorConfig) bool { 546 for _, pattern := range c.HideTypePatterns { 547 if regexp.MustCompile(pattern).MatchString(t.Name.String()) { 548 return true 549 } 550 } 551 if !isExportedType(t) && unicode.IsLower(rune(t.Name.Name[0])) { 552 // types that start with lowercase 553 return true 554 } 555 return false 556 } 557 558 func typeReferences(t *types.Type, c generatorConfig, references map[*types.Type][]*types.Type) []*types.Type { 559 var out []*types.Type 560 m := make(map[*types.Type]struct{}) 561 for _, ref := range references[t] { 562 if !hideType(ref, c) { 563 m[ref] = struct{}{} 564 } 565 } 566 for k := range m { 567 out = append(out, k) 568 } 569 sortTypes(out) 570 return out 571 } 572 573 func sortTypes(typs []*types.Type) []*types.Type { 574 sort.Slice(typs, func(i, j int) bool { 575 t1, t2 := typs[i], typs[j] 576 if isExportedType(t1) && !isExportedType(t2) { 577 return true 578 } else if !isExportedType(t1) && isExportedType(t2) { 579 return false 580 } 581 return t1.Name.String() < t2.Name.String() 582 }) 583 return typs 584 } 585 586 func visibleTypes(in []*types.Type, c generatorConfig) []*types.Type { 587 var out []*types.Type 588 for _, t := range in { 589 if !hideType(t, c) { 590 out = append(out, t) 591 } 592 } 593 return out 594 } 595 596 func filterCommentTags(comments []string) []string { 597 var out []string 598 for _, v := range comments { 599 if !strings.HasPrefix(strings.TrimSpace(v), "+") { 600 out = append(out, v) 601 } 602 } 603 return out 604 } 605 606 func isOptionalMember(m types.Member) bool { 607 tags := types.ExtractCommentTags("+", m.CommentLines) 608 _, ok := tags["optional"] 609 return ok 610 } 611 612 func apiVersionForPackage(pkg *types.Package) (string, string, error) { 613 group := groupName(pkg) 614 version := pkg.Name // assumes basename (i.e. "v1" in "core/v1") is apiVersion 615 r := `^v\d+((alpha|beta|api|stable)[a-z0-9]+)?$` 616 if !regexp.MustCompile(r).MatchString(version) { 617 return "", "", errors.Errorf("cannot infer kubernetes apiVersion of go package %s (basename %q doesn't match expected pattern %s that's used to determine apiVersion)", pkg.Path, version, r) 618 } 619 return group, version, nil 620 } 621 622 // extractTypeToPackageMap creates a *types.Type map to apiPackage 623 func extractTypeToPackageMap(pkgs []*apiPackage) map[*types.Type]*apiPackage { 624 out := make(map[*types.Type]*apiPackage) 625 for _, ap := range pkgs { 626 for _, t := range ap.Types { 627 out[t] = ap 628 } 629 for _, t := range ap.Constants { 630 out[t] = ap 631 } 632 } 633 return out 634 } 635 636 // constantsOfType finds all the constants in pkg that have the 637 // same underlying type as t. This is intended for use by enum 638 // type validation, where users need to specify one of a specific 639 // set of constant values for a field. 640 func constantsOfType(t *types.Type, pkg *apiPackage) []*types.Type { 641 var constants []*types.Type 642 643 for _, c := range pkg.Constants { 644 if c.Underlying == t { 645 constants = append(constants, c) 646 } 647 } 648 649 return sortTypes(constants) 650 } 651 652 func getAPIOrder(filename string) int { 653 if order, ok := apiOrder[filename]; ok { 654 return order 655 } 656 return 1000 657 } 658 659 func render(w io.Writer, pkgs []*apiPackage, config generatorConfig) error { 660 references := findTypeReferences(pkgs) 661 typePkgMap := extractTypeToPackageMap(pkgs) 662 663 t, err := template.New("").Funcs(map[string]interface{}{ 664 "isExportedType": isExportedType, 665 "fieldName": fieldName, 666 "fieldEmbedded": fieldEmbedded, 667 "typeIdentifier": typeIdentifier, 668 "typeDisplayName": func(t *types.Type) string { return typeDisplayName(t, config, typePkgMap) }, 669 "visibleTypes": func(t []*types.Type) []*types.Type { return visibleTypes(t, config) }, 670 "renderComments": func(s []string) string { return renderComments(s, !config.MarkdownDisabled) }, 671 "packageDisplayName": func(p *apiPackage) string { return p.identifier() }, 672 "apiGroup": func(t *types.Type) string { return apiGroupForType(t, typePkgMap) }, 673 "packageAnchorID": func(p *apiPackage) string { 674 return strings.ReplaceAll(p.identifier(), " ", "") 675 }, 676 "linkForType": func(t *types.Type) string { 677 v, err := linkForType(t, config, typePkgMap) 678 if err != nil { 679 klog.Fatal(errors.Wrapf(err, "error getting link for type=%s", t.Name)) 680 return "" 681 } 682 return v 683 }, 684 "anchorIDForType": func(t *types.Type) string { return anchorIDForLocalType(t, typePkgMap) }, 685 "safe": safe, 686 "toTitle": toTitle, 687 "sortedTypes": sortTypes, 688 "typeReferences": func(t *types.Type) []*types.Type { return typeReferences(t, config, references) }, 689 "hiddenMember": func(m types.Member) bool { return hiddenMember(m, config) }, 690 "isLocalType": isLocalType, 691 "isOptionalMember": isOptionalMember, 692 "constantsOfType": func(t *types.Type) []*types.Type { return constantsOfType(t, typePkgMap[t]) }, 693 }).ParseGlob(filepath.Join(*flTemplateDir, "*.tpl")) 694 if err != nil { 695 return errors.Wrap(err, "parse error") 696 } 697 698 apiName := strings.Split(filepath.Base(*flOutFile), ".")[0] 699 filerOrder := getAPIOrder(apiName) 700 701 return errors.Wrap(t.ExecuteTemplate(w, "packages", map[string]interface{}{ 702 "packages": pkgs, 703 "apiName": apiName, 704 "filerOrder": filerOrder, 705 }), "template execution error") 706 }