diff --git a/scripts/generate-static-site.sh b/scripts/generate-static-site.sh
new file mode 100755
index 0000000000..efec9c299a
--- /dev/null
+++ b/scripts/generate-static-site.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+# Copyright 2023 The kpt Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -o errexit
+set -o pipefail
+
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "${REPO_ROOT}"/tools/generate-static-site
+
+go build -o "${REPO_ROOT}/bin/generate-static-site" .
+
+cd "${REPO_ROOT}"
+rm -rf websites/kpt.dev
+"${REPO_ROOT}/bin/generate-static-site"
+cp -r site/static/ websites/kpt.dev/static/
\ No newline at end of file
diff --git a/site/gitops/README.md b/site/gitops/README.md
index bc92119090..b440d880ff 100644
--- a/site/gitops/README.md
+++ b/site/gitops/README.md
@@ -5,4 +5,4 @@ deployment mechanism is [Config Sync](gitops/configsync/), but since configurati
repositories, other [GitOps](https://opengitops.dev/) tools can also be used.
Currently supported gitops tools:
-* [Config Sync](gitops/configsync/)
\ No newline at end of file
+* [Config Sync](configsync/)
\ No newline at end of file
diff --git a/tools/generate-static-site/go.mod b/tools/generate-static-site/go.mod
new file mode 100644
index 0000000000..f09e596764
--- /dev/null
+++ b/tools/generate-static-site/go.mod
@@ -0,0 +1,18 @@
+module github.com/GoogleContainerTools/kpt/tools/generate-static-site
+
+go 1.20
+
+require (
+ github.com/igorsobreira/titlecase v0.0.0-20140109233139-4156b5b858ac
+ github.com/yuin/goldmark v1.4.6
+ github.com/yuin/goldmark-meta v1.1.0
+ golang.org/x/net v0.8.0
+ k8s.io/klog/v2 v2.90.1
+)
+
+require (
+ github.com/go-logr/logr v1.2.3 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+)
diff --git a/tools/generate-static-site/go.sum b/tools/generate-static-site/go.sum
new file mode 100644
index 0000000000..97d408b722
--- /dev/null
+++ b/tools/generate-static-site/go.sum
@@ -0,0 +1,25 @@
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
+github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/igorsobreira/titlecase v0.0.0-20140109233139-4156b5b858ac h1:AfRcPFr4uK97K6YaYi9XmNY/cTPF+cLspaXocdqkdCQ=
+github.com/igorsobreira/titlecase v0.0.0-20140109233139-4156b5b858ac/go.mod h1:KOzUkqpWM2xArNm82cehGc5PBFYV1Qadzzt81aJi7F0=
+github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/yuin/goldmark v1.4.6 h1:EQ1OkiNq/eMbQxs/2O/A8VDIHERXGH14s19ednd4XIw=
+github.com/yuin/goldmark v1.4.6/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
+github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
+github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
+golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+k8s.io/klog/v2 v2.90.1 h1:m4bYOKall2MmOiRaR1J+We67Do7vm9KiQVlT96lnHUw=
+k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
diff --git a/tools/generate-static-site/main.go b/tools/generate-static-site/main.go
new file mode 100644
index 0000000000..0984562bac
--- /dev/null
+++ b/tools/generate-static-site/main.go
@@ -0,0 +1,583 @@
+// Copyright 2023 The kpt Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "flag"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "golang.org/x/net/html"
+
+ "github.com/igorsobreira/titlecase"
+ "github.com/yuin/goldmark"
+ meta "github.com/yuin/goldmark-meta"
+ "github.com/yuin/goldmark/ast"
+ gast "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ mdhtml "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+ "k8s.io/klog/v2"
+)
+
+//go:embed templates/*
+var templates embed.FS
+
+const markdownExtension = ".md"
+const introPage = "00.md"
+
+var pagePrefix = regexp.MustCompile(`^\d\d-?`)
+
+func main() {
+ if err := run(context.Background()); err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(ctx context.Context) error {
+ srcBase := "site/"
+ out := "websites/kpt.dev/"
+
+ flag.Parse()
+
+ md := goldmark.New(
+ goldmark.WithExtensions(
+ meta.Meta, // ignore yaml metadata
+ extension.Table, // render tables
+ &normalizeLinks{}, // .md -> .html
+ ),
+
+ goldmark.WithRendererOptions(
+ mdhtml.WithUnsafe(),
+ ),
+ )
+
+ // Build the sidebar
+ sidebarMarkdown := ""
+ {
+ templatePath := "templates/sidebar_template.md.tmpl"
+
+ t := template.Must(
+ template.New(path.Base(templatePath)).
+ Funcs(template.FuncMap{"bookLayout": getBookOutline}).
+ ParseFS(templates, templatePath))
+
+ var w bytes.Buffer
+ fmt.Fprintf(&w, "")
+ err := t.Execute(&w, nil)
+ if err != nil {
+ return fmt.Errorf("running template: %w", err)
+ }
+ sidebarMarkdown = w.String()
+ }
+ sidebar := ""
+ {
+ var w bytes.Buffer
+ if err := md.Convert([]byte(sidebarMarkdown), &w); err != nil {
+ return fmt.Errorf("rendering markdown: %w", err)
+ }
+ sidebar = w.String()
+ }
+
+ // Go through and build pages for each markdown file
+ var pages []*Page
+ if err := filepath.WalkDir(srcBase, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ ext := filepath.Ext(path)
+
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("reading file %q: %w", path, err)
+ }
+ src := string(b)
+
+ srcPath := strings.TrimPrefix(path, srcBase)
+
+ var page *Page
+
+ ext = strings.ToLower(ext)
+ switch ext {
+ case ".html":
+ page = &Page{
+ URL: "/" + srcPath,
+ HTML: src,
+ }
+
+ case ".md":
+
+ // Remove the hugo hide directives that we are using
+ {
+ re := regexp.MustCompile("(?ms)" + regexp.QuoteMeta("{{% hide %}}") + ".+?" + regexp.QuoteMeta("{{% /hide %}}"))
+ src = re.ReplaceAllLiteralString(src, "")
+ re2 := regexp.MustCompile("{{.*}}")
+ src = re2.ReplaceAllLiteralString(src, "")
+ }
+
+ var htmlString string
+ {
+ var w bytes.Buffer
+ mdContext := parser.NewContext()
+ if err := md.Convert([]byte(src), &w, parser.WithContext(mdContext)); err != nil {
+ return fmt.Errorf("rendering markdown: %w", err)
+ }
+ htmlString = w.String()
+
+ metaData := meta.Get(mdContext)
+
+ title := ""
+ if v, ok := metaData["title"]; ok {
+ title = v.(string)
+ }
+
+ if strings.HasPrefix(srcPath, "book/") {
+ title = getBookPageTitle(srcPath)
+ if title == "" {
+ klog.Warningf("cannot parse book page title for %q", path)
+ }
+ }
+ if title != "" {
+ var w2 bytes.Buffer
+ if err := md.Convert([]byte(title), &w2); err != nil {
+ return fmt.Errorf("rendering markdown: %w", err)
+ }
+
+ htmlString = "
" + w2.String() + "
" + htmlString
+ }
+ }
+
+ isCover := srcPath == "coverpage.md"
+ if isCover {
+ templateName := "templates/cover.html"
+ template, err := templates.ReadFile(templateName)
+ if err != nil {
+ return fmt.Errorf("reading template %q: %w", templateName, err)
+ }
+
+ htmlString = strings.ReplaceAll(string(template), "", htmlString)
+ }
+
+ {
+ templateName := "templates/main.html"
+ template, err := templates.ReadFile(templateName)
+ if err != nil {
+ return fmt.Errorf("reading template %q: %w", templateName, err)
+ }
+
+ htmlString = strings.ReplaceAll(string(template), "", htmlString)
+ }
+ {
+ templateName := "templates/index.html"
+ template, err := templates.ReadFile(templateName)
+ if err != nil {
+ return fmt.Errorf("reading template %q: %w", templateName, err)
+ }
+
+ htmlString = strings.ReplaceAll(string(template), "", htmlString)
+ }
+
+ if strings.Contains(htmlString, "") {
+ htmlString = strings.ReplaceAll(htmlString, "", sidebar)
+ }
+
+ page = &Page{
+ URL: "/" + srcPath,
+ HTML: htmlString,
+ }
+
+ default:
+ klog.V(2).Infof("ignoring file with unknown extension %q", srcPath)
+ }
+
+ if page != nil {
+ page.URL = strings.TrimSuffix(page.URL, "/README.md")
+ page.URL = strings.TrimSuffix(page.URL, "/00.md")
+ page.URL = strings.TrimSuffix(page.URL, ".md")
+
+ pages = append(pages, page)
+ }
+
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ for _, page := range pages {
+ v := &PageVisitor{
+ Page: page,
+ }
+ if err := v.postProcessHTML(); err != nil {
+ return err
+ }
+ }
+
+ for _, page := range pages {
+ outURL := page.URL
+
+ if !strings.HasSuffix(outURL, ".html") {
+ outURL = outURL + ".html"
+ }
+
+ outPath := filepath.Join(out, strings.TrimPrefix(outURL, "/"))
+ klog.V(2).Infof("writing file %v", outPath)
+
+ outDir := filepath.Dir(outPath)
+ if err := os.MkdirAll(outDir, 0755); err != nil {
+ return fmt.Errorf("making directories %q: %w", outDir, err)
+ }
+
+ if err := os.WriteFile(outPath, []byte(page.HTML), 0644); err != nil {
+ return fmt.Errorf("writing file %q: %w", outPath, err)
+ }
+ }
+
+ return nil
+}
+
+func getBookOutline() string {
+ sourcePath := "site/book"
+ chapters := collectChapters(sourcePath)
+
+ return getChapterBlock(chapters)
+}
+
+func collectChapters(source string) []chapter {
+ chapters := make([]chapter, 0)
+ chapterDirs, err := os.ReadDir(source)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ os.Exit(1)
+ }
+
+ for _, dir := range chapterDirs {
+ if dir.IsDir() {
+ chapters = append(chapters, getChapter(dir.Name(), filepath.Join(source, dir.Name())))
+ }
+ }
+
+ return chapters
+}
+
+func getChapter(chapterDirName string, chapterDirPath string) chapter {
+ chapterBuilder := chapter{}
+
+ // Split into chapter number and hyphenated name
+ splitDirName := strings.SplitN(chapterDirName, "-", 2)
+ chapterBuilder.Number = splitDirName[0]
+ chapterBuilder.Name = titlecase.Title(strings.ReplaceAll(splitDirName[1], "-", " "))
+
+ pageFiles, err := os.ReadDir(chapterDirPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ os.Exit(1)
+ }
+
+ for _, pageFile := range pageFiles {
+ if filepath.Ext(pageFile.Name()) == markdownExtension && pagePrefix.MatchString(pageFile.Name()) {
+ chapterBuilder.Pages = append(chapterBuilder.Pages,
+ getPage(pageFile.Name(), chapterBuilder.Name, chapterDirPath))
+ }
+ }
+
+ return chapterBuilder
+}
+
+func getPage(pageFileName string, defaultName string, parentPath string) page {
+ // Split into page number and hyphenated name.
+ splitPageName := strings.SplitN(pageFileName, "-", 2)
+
+ pageName := defaultName
+ if pageFileName != introPage {
+ // Strip page number and extension from file name.
+ pageTitle := pagePrefix.ReplaceAll([]byte(pageFileName), []byte(""))
+ pageName = titlecase.Title(strings.ReplaceAll(strings.ReplaceAll(string(pageTitle), ".md", ""), "-", " "))
+ }
+
+ p := page{
+ Number: splitPageName[0],
+ Name: pageName,
+ Path: filepath.Join(parentPath, pageFileName),
+ }
+
+ if pageFileName == introPage {
+ p.Path = parentPath
+ }
+ return p
+}
+
+func getChapterBlock(chapters []chapter) string {
+ // Sort chapters in ascending order by chapter number.
+ sort.Slice(chapters, func(i, j int) bool { return chapters[i].Number < chapters[j].Number })
+ var sb strings.Builder
+ for chapterIndex, chapterEntry := range chapters {
+ chapterNumber := chapterIndex + 1
+ for pageIndex, pageEntry := range chapterEntry.Pages {
+ // Make path relative to site directory.
+ path := strings.Replace(pageEntry.Path, "site/", "/", 1)
+
+ // Print non-chapter intro pages as children of chapter intro page.
+ if pageIndex == 0 {
+ sb.WriteString(fmt.Sprintf("\t- [%d %s](%s)\n", chapterNumber, pageEntry.Name, path))
+ } else {
+ sb.WriteString(fmt.Sprintf("\t\t- [%d.%d %s](%s)\n", chapterNumber, pageIndex, pageEntry.Name, path))
+ }
+ }
+ }
+ return strings.TrimRight(sb.String(), "\n")
+}
+
+type chapter struct {
+ Name string
+ Pages []page
+ Number string
+}
+
+type page struct {
+ Name string
+ Path string
+ Number string
+}
+
+type normalizeLinks struct {
+}
+
+func (x *normalizeLinks) Extend(md goldmark.Markdown) {
+ md.Parser().AddOptions(parser.WithASTTransformers(
+ util.Prioritized(&normalizeLinksTransformer{}, 999),
+ ))
+}
+
+type normalizeLinksTransformer struct {
+}
+
+func (a *normalizeLinksTransformer) Transform(node *gast.Document, reader text.Reader, pc parser.Context) {
+ a.visit(node, reader.Source())
+}
+
+func (a *normalizeLinksTransformer) visit(node gast.Node, src []byte) {
+ switch node := node.(type) {
+ case *ast.Link:
+ dest := string(node.Destination)
+ if strings.HasSuffix(dest, ".md") {
+ dest = strings.TrimSuffix(dest, ".md")
+ }
+ node.Destination = []byte(dest)
+ default:
+ klog.V(2).Infof("node type %T not implmented", node)
+ }
+
+ pos := node.FirstChild()
+ for pos != nil {
+ a.visit(pos, src)
+ pos = pos.NextSibling()
+ }
+}
+
+type PageVisitor struct {
+ Page *Page
+}
+
+func (v *PageVisitor) postProcessHTML() error {
+ doc, err := html.Parse(bytes.NewReader([]byte(v.Page.HTML)))
+ if err != nil {
+ return fmt.Errorf("parsing html: %w", err)
+ }
+
+ var f func(*html.Node)
+ f = func(n *html.Node) {
+ if hasClassName(n, "sidebar-nav") {
+ v.addActiveSidebar(n)
+ }
+
+ if hasClassName(n, "sidebar-nav") {
+ addSidebarCollapsibility(n)
+ }
+
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ f(c)
+ }
+ }
+ f(doc)
+
+ var w bytes.Buffer
+ if err := html.Render(&w, doc); err != nil {
+ return fmt.Errorf("rendering html: %w", err)
+ }
+ v.Page.HTML = w.String()
+ return nil
+}
+
+// Only show child pages for currently active page to avoid sidebar cluttering.
+func (v *PageVisitor) addActiveSidebar(n *html.Node) {
+ for _, li := range getElementsByTagName(n, "li") {
+ for _, a := range getElementsByTagName(li, "a") {
+ href := getAttr(a, "href")
+ href = strings.TrimSuffix(href, "/")
+ if href == v.Page.URL {
+ addClass(li, "active")
+ }
+ }
+ }
+}
+
+// Only show child pages for currently active page to avoid sidebar cluttering.
+func addSidebarCollapsibility(n *html.Node) {
+ uls := getElementsByTagName(n, "ul")
+
+ for _, ul := range uls {
+ if hasClassName(ul.Parent, "active") {
+ continue
+ }
+
+ if len(getElementsByClassName(ul, "active")) == 0 {
+ addClass(ul, "inactive")
+ }
+ }
+}
+
+func getElementsByTagName(el *html.Node, tagName string) []*html.Node {
+ var ret []*html.Node
+ var f func(*html.Node)
+ f = func(n *html.Node) {
+ if n.Type == html.ElementNode && n.Data == tagName {
+ ret = append(ret, n)
+ }
+
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ f(c)
+ }
+ }
+ f(el)
+
+ return ret
+}
+
+func getElementsByClassName(el *html.Node, className string) []*html.Node {
+ var ret []*html.Node
+ var f func(*html.Node)
+ f = func(n *html.Node) {
+ if hasClassName(n, className) {
+ ret = append(ret, n)
+ }
+
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ f(c)
+ }
+ }
+ f(el)
+
+ return ret
+}
+
+func hasClassName(el *html.Node, className string) bool {
+ for _, attr := range el.Attr {
+ if attr.Key == "class" {
+ tokens := strings.Split(attr.Val, " ")
+ for _, token := range tokens {
+ if token == className {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+func getAttr(el *html.Node, attrKey string) string {
+ for _, attr := range el.Attr {
+ if attr.Key == attrKey {
+ return attr.Val
+ }
+ }
+ return ""
+}
+
+func addClass(el *html.Node, className string) {
+ for _, attr := range el.Attr {
+ if attr.Key == "class" {
+ tokens := strings.Split(attr.Val, " ")
+ for _, token := range tokens {
+ if token == className {
+ return
+ }
+ }
+ tokens = append(tokens, className)
+ attr.Val = strings.Join(tokens, " ")
+ return
+ }
+ }
+ el.Attr = append(el.Attr, html.Attribute{
+ Key: "class",
+ Val: className,
+ })
+}
+
+type Site struct {
+ Pages map[string]*Page
+}
+
+type Page struct {
+ URL string
+
+ HTML string
+}
+
+func getBookPageTitle(path string) string {
+ path = strings.TrimPrefix(path, "book/")
+ path = strings.TrimSuffix(path, ".md")
+ path = strings.TrimSuffix(path, "/00")
+
+ var heading string
+
+ components := strings.Split(path, "/")
+ for i, component := range components {
+ tokens := strings.SplitN(component, "-", 2)
+ if len(tokens) != 2 {
+ return ""
+ }
+
+ n, err := strconv.Atoi(tokens[0])
+ if err != nil {
+ return ""
+ }
+ if heading != "" {
+ heading += "."
+ }
+ heading = heading + fmt.Sprintf("%v", n)
+ if i == len(components)-1 {
+ return heading + " " + titlecase.Title(strings.ReplaceAll(tokens[1], "-", " "))
+ }
+ }
+
+ return ""
+}
diff --git a/tools/generate-static-site/templates/cover.html b/tools/generate-static-site/templates/cover.html
new file mode 100644
index 0000000000..6d7eb0cc67
--- /dev/null
+++ b/tools/generate-static-site/templates/cover.html
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/tools/generate-static-site/templates/index.html b/tools/generate-static-site/templates/index.html
new file mode 100644
index 0000000000..6dca49c0c2
--- /dev/null
+++ b/tools/generate-static-site/templates/index.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+ kpt - Home
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/generate-static-site/templates/main.html b/tools/generate-static-site/templates/main.html
new file mode 100644
index 0000000000..7a5810adc4
--- /dev/null
+++ b/tools/generate-static-site/templates/main.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/generate-static-site/templates/sidebar_template.md.tmpl b/tools/generate-static-site/templates/sidebar_template.md.tmpl
new file mode 100644
index 0000000000..770a3b738a
--- /dev/null
+++ b/tools/generate-static-site/templates/sidebar_template.md.tmpl
@@ -0,0 +1,76 @@
+
+- [Installation](/installation/)
+- [Book](/book/)
+{{bookLayout}}
+- [Reference](/reference/)
+ - [Annotations](/reference/annotations/)
+ - [apply-time mutation](/reference/annotations/apply-time-mutation/)
+ - [depends-on](/reference/annotations/depends-on/)
+ - [local-config](/reference/annotations/local-config/)
+ - [CLI](/reference/cli/)
+ - [pkg](/reference/cli/pkg/)
+ - [diff](/reference/cli/pkg/diff/)
+ - [get](/reference/cli/pkg/get/)
+ - [init](/reference/cli/pkg/init/)
+ - [tree](/reference/cli/pkg/tree/)
+ - [update](/reference/cli/pkg/update/)
+ - [fn](/reference/cli/fn/)
+ - [render](/reference/cli/fn/render/)
+ - [eval](/reference/cli/fn/eval/)
+ - [sink](/reference/cli/fn/sink/)
+ - [source](/reference/cli/fn/source/)
+ - [live](/reference/cli/live/)
+ - [apply](/reference/cli/live/apply/)
+ - [destroy](/reference/cli/live/destroy/)
+ - [init](/reference/cli/live/init/)
+ - [install-resource-group](/reference/cli/live/install-resource-group/)
+ - [migrate](/reference/cli/live/migrate/)
+ - [status](/reference/cli/live/status/)
+ - [alpha](/reference/cli/alpha/)
+ - [license](/reference/cli/alpha/license/)
+ - [info](/reference/cli/alpha/license/info/)
+ - [live](/reference/cli/alpha/live/)
+ - [plan](/reference/cli/alpha/live/plan/)
+ - [repo](/reference/cli/alpha/repo/)
+ - [get](/reference/cli/alpha/repo/get/)
+ - [reg](/reference/cli/alpha/repo/reg/)
+ - [unreg](/reference/cli/alpha/repo/unreg/)
+ - [rpkg](/reference/cli/alpha/rpkg/)
+ - [get](/reference/cli/alpha/rpkg/get/)
+ - [pull](/reference/cli/alpha/rpkg/pull/)
+ - [push](/reference/cli/alpha/rpkg/push/)
+ - [clone](/reference/cli/alpha/rpkg/clone/)
+ - [init](/reference/cli/alpha/rpkg/init/)
+ - [propose](/reference/cli/alpha/rpkg/propose/)
+ - [approve](/reference/cli/alpha/rpkg/approve/)
+ - [del](/reference/cli/alpha/rpkg/del/)
+ - [propose-delete](/reference/cli/alpha/rpkg/propose-delete/)
+ - [reject](/reference/cli/alpha/rpkg/reject/)
+ - [copy](/reference/cli/alpha/rpkg/copy/)
+ - [sync](/reference/cli/alpha/sync/)
+ - [create](/reference/cli/alpha/sync/create/)
+ - [delete](/reference/cli/alpha/sync/delete/)
+ - [get](/reference/cli/alpha/sync/get/)
+ - [Schema](/reference/schema/)
+ - [Kptfile](/reference/schema/kptfile/)
+ - [FunctionResultList](/reference/schema/function-result-list/)
+ - [ResourceList](/reference/schema/resource-list/)
+ - [CRD Status Convention](/reference/schema/crd-status-convention/)
+ - [Config Connector Status Convention](/reference/schema/config-connector-status-convention/)
+ - [Plan](/reference/schema/plan/)
+- [Functions Catalog](https://catalog.kpt.dev/ ":target=_self")
+ - [Curated](https://catalog.kpt.dev/ ":target=_self")
+ - [Contrib](https://catalog.kpt.dev/contrib/ ":target=_self")
+- [GitOps](/gitops/)
+ - [Config Sync](/gitops/configsync/)
+- [Guides](/guides/)
+ - [The Rationale Behind kpt](/guides/rationale.md)
+ - [Porch Installation Guide](/guides/porch-installation.md)
+ - [Porch UI Installation Guide](/guides/porch-ui-installation.md)
+ - [Porch User Guide](/guides/porch-user-guide.md)
+ - [Namespace Provisioning CLI](/guides/namespace-provisioning-cli.md)
+ - [Namespace Provisioning UI](/guides/namespace-provisioning-ui.md)
+ - [Variant Constructor Pattern](/guides/variant-constructor-pattern.md)
+ - [Value Propagation Pattern](/guides/value-propagation.md)
+- [FAQ](/faq/)
+- [Contact](/contact/)
diff --git a/websites/cmd/siteserver-kpt-dev/kodata/static b/websites/cmd/siteserver-kpt-dev/kodata/static
new file mode 120000
index 0000000000..067c76f6cb
--- /dev/null
+++ b/websites/cmd/siteserver-kpt-dev/kodata/static
@@ -0,0 +1 @@
+../../../kpt.dev/
\ No newline at end of file
diff --git a/websites/cmd/siteserver-kpt-dev/main.go b/websites/cmd/siteserver-kpt-dev/main.go
new file mode 100644
index 0000000000..e5ffc16662
--- /dev/null
+++ b/websites/cmd/siteserver-kpt-dev/main.go
@@ -0,0 +1,107 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "k8s.io/klog/v2"
+)
+
+func main() {
+ ctx := context.Background()
+ if err := run(ctx); err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(ctx context.Context) error {
+ listen := ":8080"
+ staticRoot := "./kpt.dev"
+ if s := os.Getenv("KO_DATA_PATH"); s != "" {
+ staticRoot = filepath.Join(s, "static")
+ }
+ flag.Parse()
+
+ httpRoot := http.Dir(staticRoot)
+ httpFileServer := http.FileServer(httpRoot)
+
+ allFiles := make(map[string]fs.DirEntry)
+ if err := fs.WalkDir(os.DirFS(staticRoot), ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ path = "/" + path
+ allFiles[path] = d
+ klog.Infof("path %v", path)
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ rewrites := make(map[string]string)
+ for k, d := range allFiles {
+ if d.IsDir() {
+ if allFiles[k+".html"] != nil {
+ withoutSlash := strings.TrimSuffix(k, "/")
+ rewrites[withoutSlash] = k + ".html"
+ rewrites[withoutSlash+"/"] = k + ".html"
+ }
+ } else if strings.HasSuffix(k, ".html") {
+ rewrites[strings.TrimSuffix(k, ".html")] = k
+ rewrites[strings.TrimSuffix(k, ".html")+"/"] = k
+ }
+ }
+
+ http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ upath := r.URL.Path
+ if !strings.HasPrefix(upath, "/") {
+ upath = "/" + upath
+ r.URL.Path = upath
+ }
+
+ rewrite, ok := rewrites[r.URL.Path]
+ if ok {
+ r.URL.Path = rewrite
+ }
+ // f, err := httpRoot.Open(upath)
+ // if err != nil {
+ // if os.IsNotExist(err) {
+ // if _, err := httpRoot.Open(upath + ".html"); err == nil {
+ // r.URL.Path += ".html"
+ // }
+ // }
+ // } else {
+ // d, err := f.Stat()
+ // if err == nil {
+ // if d.IsDir() {
+ // if _, err := httpRoot.Open(upath + "index.html"); err == nil {
+ // // http server does this by default
+ // } else if _, err := httpRoot.Open(upath + ".html"); err == nil {
+ // r.URL.Path += ".html"
+ // } else if _, err := httpRoot.Open(upath + "README.html"); err == nil {
+ // r.URL.Path += "README.html"
+ // } else {
+ // s := strings.TrimSuffix(upath, "/")
+ // if _, err := httpRoot.Open(s + ".html"); err == nil {
+ // r.URL.Path += ".html"
+ // }
+ // }
+ // }
+ // }
+ httpFileServer.ServeHTTP(w, r)
+ })
+
+ klog.Infof("serving %q on %v", staticRoot, listen)
+ err := http.ListenAndServe(listen, nil)
+ if err != nil {
+ return fmt.Errorf("error serving on %q: %w", listen, err)
+ }
+ return nil
+}