├── .github └── workflows │ ├── go-cross.yml │ └── main.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── core ├── core.go └── core_test.go ├── docker ├── docker.go ├── docker_test.go └── fixtures │ └── docs.Dockerfile ├── file ├── copy.go ├── download.go ├── download_test.go └── fixtures │ └── data.txt ├── gh ├── gh.go └── gh_test.go ├── go.mod ├── go.sum ├── godownloader.sh ├── manifest ├── fixtures │ ├── empty-mkdocs.yml │ ├── sample-mkdocs.yml │ └── traefik-mkdocs.yml ├── manifest.go └── manifest_test.go ├── menu ├── css.go ├── fixtures │ ├── mkdocs.yml │ ├── mkdocs_without-extra.yml │ ├── server │ │ ├── test-menu.css.gotmpl │ │ └── test-menu.js.gotmpl │ ├── test-menu.css.gotmpl │ ├── test-menu.js.gotmpl │ ├── test_custom-css-1.yml │ ├── test_custom-css-2.yml │ ├── test_custom-js-1.yml │ ├── test_custom-js-2.yml │ ├── test_no-custom-files.yml │ ├── traefik-menu-obsolete.js │ └── traefik-menu.js ├── js.go ├── js_test.go ├── manifest.go ├── manifest_test.go ├── menu.go └── menu_test.go ├── readme.md ├── repository ├── repository.go └── repository_test.go ├── requirements-override.txt ├── requirements ├── fixtures │ └── requirements.txt ├── requirements.go └── requirements_test.go ├── structor.go ├── traefik-menu.js.gotmpl ├── types └── types.go └── version.go /.github/workflows/go-cross.yml: -------------------------------------------------------------------------------- 1 | name: Go Matrix 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | 6 | cross: 7 | name: Go 8 | runs-on: ${{ matrix.os }} 9 | env: 10 | CGO_ENABLED: 0 11 | 12 | strategy: 13 | matrix: 14 | go-version: [ '1.20', 1.x ] 15 | # os: [ubuntu-latest, macos-latest, windows-latest] 16 | os: [ubuntu-latest, macos-latest] 17 | 18 | steps: 19 | # https://github.com/marketplace/actions/setup-go-environment 20 | - name: Set up Go ${{ matrix.go-version }} 21 | uses: actions/setup-go@v2 22 | with: 23 | go-version: ${{ matrix.go-version }} 24 | 25 | # https://github.com/marketplace/actions/checkout 26 | - name: Checkout code 27 | uses: actions/checkout@v2 28 | 29 | # https://github.com/marketplace/actions/cache 30 | - name: Cache Go modules 31 | uses: actions/cache@v3 32 | with: 33 | # In order: 34 | # * Module download cache 35 | # * Build cache (Linux) 36 | # * Build cache (Mac) 37 | # * Build cache (Windows) 38 | path: | 39 | ~/go/pkg/mod 40 | ~/.cache/go-build 41 | ~/Library/Caches/go-build 42 | %LocalAppData%\go-build 43 | key: ${{ runner.os }}-${{ matrix.go-version }}-go-${{ hashFiles('**/go.sum') }} 44 | restore-keys: | 45 | ${{ runner.os }}-${{ matrix.go-version }}-go- 46 | 47 | - name: Test 48 | run: go test -v -cover ./... 49 | 50 | - name: Build 51 | run: go build -v -ldflags "-s -w" -trimpath 52 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | pull_request: 10 | 11 | jobs: 12 | 13 | main: 14 | name: Main Process 15 | runs-on: ubuntu-latest 16 | env: 17 | GO_VERSION: '1.20' 18 | GOLANGCI_LINT_VERSION: v1.51.2 19 | HUGO_VERSION: 0.54.0 20 | CGO_ENABLED: 0 21 | 22 | steps: 23 | 24 | # https://github.com/marketplace/actions/setup-go-environment 25 | - name: Set up Go ${{ env.GO_VERSION }} 26 | uses: actions/setup-go@v2 27 | with: 28 | go-version: ${{ env.GO_VERSION }} 29 | 30 | # https://github.com/marketplace/actions/checkout 31 | - name: Check out code 32 | uses: actions/checkout@v2 33 | with: 34 | fetch-depth: 0 35 | 36 | # https://github.com/marketplace/actions/cache 37 | - name: Cache Go modules 38 | uses: actions/cache@v3 39 | with: 40 | path: ~/go/pkg/mod 41 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 42 | restore-keys: | 43 | ${{ runner.os }}-go- 44 | 45 | - name: Check and get dependencies 46 | run: | 47 | go mod tidy 48 | git diff --exit-code go.mod 49 | git diff --exit-code go.sum 50 | 51 | # https://golangci-lint.run/usage/install#other-ci 52 | - name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }} 53 | run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION} 54 | 55 | - name: Make 56 | run: make 57 | 58 | # https://goreleaser.com/ci/actions/ 59 | - name: Run GoReleaser 60 | uses: goreleaser/goreleaser-action@v2 61 | if: startsWith(github.ref, 'refs/tags/v') 62 | with: 63 | version: latest 64 | args: release --rm-dist 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN_REPO }} 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .idea/ 3 | .vscode/ 4 | *.log 5 | *.exe 6 | .DS_Store 7 | vendor/ 8 | structor 9 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | skip-files: [ ] 4 | skip-dirs: [ ] 5 | 6 | linters-settings: 7 | govet: 8 | enable-all: true 9 | disable: 10 | - fieldalignment 11 | gocyclo: 12 | min-complexity: 15 13 | maligned: 14 | suggest-new: true 15 | goconst: 16 | min-len: 5 17 | min-occurrences: 3 18 | misspell: 19 | locale: US 20 | funlen: 21 | lines: -1 22 | statements: 40 23 | godox: 24 | keywords: 25 | - FIXME 26 | gofumpt: 27 | extra-rules: true 28 | depguard: 29 | list-type: blacklist 30 | include-go-root: false 31 | packages: 32 | - github.com/sirupsen/logrus 33 | - github.com/pkg/errors 34 | gocritic: 35 | enabled-tags: 36 | - diagnostic 37 | - style 38 | - performance 39 | disabled-checks: 40 | - unnamedResult 41 | - sloppyReassign 42 | - rangeValCopy 43 | - octalLiteral 44 | - paramTypeCombine # already handle by gofumpt.extra-rules 45 | settings: 46 | hugeParam: 47 | sizeThreshold: 100 48 | 49 | linters: 50 | enable-all: true 51 | disable: 52 | - deadcode # deprecated 53 | - exhaustivestruct # deprecated 54 | - golint # deprecated 55 | - ifshort # deprecated 56 | - interfacer # deprecated 57 | - maligned # deprecated 58 | - nosnakecase # deprecated 59 | - scopelint # deprecated 60 | - scopelint # deprecated 61 | - structcheck # deprecated 62 | - varcheck # deprecated 63 | - sqlclosecheck # not relevant (SQL) 64 | - rowserrcheck # not relevant (SQL) 65 | - execinquery # not relevant (SQL) 66 | - cyclop # duplicate of gocyclo 67 | - lll 68 | - dupl 69 | - wsl 70 | - nlreturn 71 | - gomnd 72 | - goerr113 73 | - wrapcheck 74 | - exhaustive 75 | - exhaustruct 76 | - testpackage 77 | - tparallel 78 | - paralleltest 79 | - prealloc 80 | - ifshort 81 | - forcetypeassert 82 | - forbidigo 83 | - noctx 84 | - varnamelen 85 | 86 | issues: 87 | exclude-use-default: false 88 | max-per-linter: 0 89 | max-same-issues: 0 90 | exclude: 91 | - 'ST1000: at least one file in a package should have a package comment' 92 | - 'package-comments: should have a package comment' 93 | - "G304: Potential file inclusion via variable" 94 | - "G204: Subprocess launched with variable" 95 | - "G107: Potential HTTP request made with variable url" 96 | exclude-rules: 97 | - path: .*_test.go 98 | linters: 99 | - funlen 100 | - path: version.go 101 | text: (version|date|commit) is a global variable 102 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: structor 2 | 3 | builds: 4 | - binary: structor 5 | goos: 6 | - windows 7 | - darwin 8 | - linux 9 | goarch: 10 | - amd64 11 | - 386 12 | - arm 13 | - arm64 14 | goarm: 15 | - 7 16 | 17 | ignore: 18 | - goos: darwin 19 | goarch: 386 20 | 21 | changelog: 22 | sort: asc 23 | filters: 24 | exclude: 25 | - '^docs:' 26 | - '^doc:' 27 | - '^chore:' 28 | - '^test:' 29 | - '^tests:' 30 | 31 | archives: 32 | - id: structor 33 | name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 34 | format: tar.gz 35 | format_overrides: 36 | - goos: windows 37 | format: zip 38 | files: 39 | - LICENSE 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018-2020 Containous SAS 190 | Copyright 2020 Traefik Labs 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: default fmt clean checks test build 2 | 3 | export GO111MODULE=on 4 | 5 | GOFILES := $(shell git ls-files '*.go' | grep -v '^vendor/') 6 | 7 | TAG_NAME := $(shell git tag -l --contains HEAD) 8 | SHA := $(shell git rev-parse --short HEAD) 9 | VERSION := $(if $(TAG_NAME),$(TAG_NAME),$(SHA)) 10 | BUILD_DATE := $(shell date -u '+%Y-%m-%d_%I:%M:%S%p') 11 | 12 | default: clean checks test build 13 | 14 | test: clean 15 | go test -v -cover ./... 16 | 17 | clean: 18 | rm -f cover.out 19 | 20 | build: clean 21 | @echo Version: $(VERSION) $(BUILD_DATE) 22 | go build -v -ldflags '-X "main.version=${VERSION}" -X "main.commit=${SHA}" -X "main.date=${BUILD_DATE}"' 23 | 24 | checks: 25 | golangci-lint run 26 | 27 | fmt: 28 | @gofmt -s -l -w $(GOFILES) 29 | -------------------------------------------------------------------------------- /core/core.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/ldez/go-git-cmd-wrapper/git" 12 | "github.com/ldez/go-git-cmd-wrapper/worktree" 13 | "github.com/traefik/structor/docker" 14 | "github.com/traefik/structor/file" 15 | "github.com/traefik/structor/gh" 16 | "github.com/traefik/structor/manifest" 17 | "github.com/traefik/structor/menu" 18 | "github.com/traefik/structor/repository" 19 | "github.com/traefik/structor/requirements" 20 | "github.com/traefik/structor/types" 21 | ) 22 | 23 | const ( 24 | baseRemote = "origin/" 25 | envVarLatestTag = "STRUCTOR_LATEST_TAG" 26 | ) 27 | 28 | // Execute core process. 29 | func Execute(config *types.Configuration) error { 30 | workDir, err := os.MkdirTemp("", "structor") 31 | if err != nil { 32 | return fmt.Errorf("failed to create temp directory: %w", err) 33 | } 34 | 35 | defer func() { 36 | if err = cleanAll(workDir, config.Debug); err != nil { 37 | log.Println("[WARN] error during cleaning: ", err) 38 | } 39 | }() 40 | 41 | if config.Debug { 42 | log.Printf("Temp directory: %s", workDir) 43 | } 44 | 45 | return process(workDir, config) 46 | } 47 | 48 | func process(workDir string, config *types.Configuration) error { 49 | menuContent := menu.GetTemplateContent(config.Menu) 50 | 51 | fallbackDockerfile, err := docker.GetDockerfileFallback(config.DockerfileURL, config.DockerImageName) 52 | if err != nil { 53 | return fmt.Errorf("failed to get Dockerfile fallback: %w", err) 54 | } 55 | 56 | requirementsContent, err := requirements.GetContent(config.RequirementsURL) 57 | if err != nil { 58 | return fmt.Errorf("failed to get requirements content: %w", err) 59 | } 60 | 61 | latestTagName, err := getLatestReleaseTagName(config.Owner, config.RepositoryName) 62 | if err != nil { 63 | return fmt.Errorf("failed to get latest release: %w", err) 64 | } 65 | 66 | log.Printf("Latest tag: %s", latestTagName) 67 | 68 | branches, err := getBranches(config.ExperimentalBranchName, config.ExcludedBranches, config.Debug) 69 | if err != nil { 70 | return fmt.Errorf("failed to get branches: %w", err) 71 | } 72 | 73 | siteDir, err := createSiteDirectory() 74 | if err != nil { 75 | return fmt.Errorf("failed to create site directory: %w", err) 76 | } 77 | 78 | for _, branchRef := range branches { 79 | versionName := strings.Replace(branchRef, baseRemote, "", 1) 80 | log.Printf("Generating doc for version %s", versionName) 81 | 82 | versionCurrentPath := filepath.Join(workDir, versionName) 83 | 84 | err := repository.CreateWorkTree(versionCurrentPath, branchRef, config.Debug) 85 | if err != nil { 86 | return fmt.Errorf("failed to create worktree: %w", err) 87 | } 88 | 89 | versionDocsRoot, err := getDocumentationRoot(versionCurrentPath) 90 | if err != nil { 91 | return fmt.Errorf("failed to get documentation path: %w", err) 92 | } 93 | 94 | err = requirements.Check(versionDocsRoot) 95 | if err != nil { 96 | return fmt.Errorf("failed to check requirements: %w", err) 97 | } 98 | 99 | versionsInfo := types.VersionsInformation{ 100 | Current: versionName, 101 | Latest: latestTagName, 102 | Experimental: config.ExperimentalBranchName, 103 | CurrentPath: versionDocsRoot, 104 | } 105 | 106 | fallbackDockerfile.Path = filepath.Join(versionsInfo.CurrentPath, fallbackDockerfile.Name) 107 | 108 | err = buildDocumentation(branches, versionsInfo, fallbackDockerfile, menuContent, requirementsContent, config) 109 | if err != nil { 110 | return fmt.Errorf("failed to build documentation: %w", err) 111 | } 112 | 113 | err = copyVersionSiteToOutputSite(versionsInfo, siteDir) 114 | if err != nil { 115 | return fmt.Errorf("failed to copy site directory: %w", err) 116 | } 117 | } 118 | 119 | return nil 120 | } 121 | 122 | func getLatestReleaseTagName(owner, repositoryName string) (string, error) { 123 | latest := os.Getenv(envVarLatestTag) 124 | if len(latest) > 0 { 125 | return latest, nil 126 | } 127 | 128 | return gh.GetLatestReleaseTagName(owner, repositoryName) 129 | } 130 | 131 | func getBranches(experimentalBranchName string, excludedBranches []string, debug bool) ([]string, error) { 132 | var branches []string 133 | 134 | if len(experimentalBranchName) > 0 { 135 | branches = append(branches, baseRemote+experimentalBranchName) 136 | } 137 | 138 | gitBranches, err := repository.ListBranches(debug) 139 | if err != nil { 140 | return nil, fmt.Errorf("failed to list branches: %w", err) 141 | } 142 | 143 | for _, branch := range gitBranches { 144 | if containsBranch(excludedBranches, branch) { 145 | continue 146 | } 147 | 148 | branches = append(branches, branch) 149 | } 150 | 151 | if len(branches) == 0 { 152 | log.Println("[WARN] no branch.") 153 | } 154 | 155 | return branches, nil 156 | } 157 | 158 | func containsBranch(branches []string, branch string) bool { 159 | for _, v := range branches { 160 | if baseRemote+v == branch { 161 | return true 162 | } 163 | } 164 | 165 | return false 166 | } 167 | 168 | func createSiteDirectory() (string, error) { 169 | currentDir, err := os.Getwd() 170 | if err != nil { 171 | return "", fmt.Errorf("failed to get current directory: %w", err) 172 | } 173 | 174 | siteDir := filepath.Join(currentDir, "site") 175 | err = createDirectory(siteDir) 176 | if err != nil { 177 | return "", fmt.Errorf("failed to create site directory: %w", err) 178 | } 179 | 180 | return siteDir, nil 181 | } 182 | 183 | func createDirectory(directoryPath string) error { 184 | _, err := os.Stat(directoryPath) 185 | switch { 186 | case err == nil: 187 | if err = os.RemoveAll(directoryPath); err != nil { 188 | return err 189 | } 190 | case !os.IsNotExist(err): 191 | return err 192 | } 193 | 194 | return os.MkdirAll(directoryPath, os.ModePerm) 195 | } 196 | 197 | // getDocumentationRoot returns the path to the documentation's root by searching for "${menu.ManifestFileName}". 198 | // Search is done from the docsRootSearchPath, relatively to the provided repository path. 199 | // An additional sanity checking is done on the file named "requirements.txt" which must be located in the same directory. 200 | func getDocumentationRoot(repositoryRoot string) (string, error) { 201 | docsRootSearchPaths := []string{"/", "docs/"} 202 | 203 | for _, docsRootSearchPath := range docsRootSearchPaths { 204 | candidateDocsRootPath := filepath.Join(repositoryRoot, docsRootSearchPath) 205 | 206 | if _, err := os.Stat(filepath.Join(candidateDocsRootPath, manifest.FileName)); !os.IsNotExist(err) { 207 | log.Printf("Found %s for building documentation in %s.", manifest.FileName, candidateDocsRootPath) 208 | return candidateDocsRootPath, nil 209 | } 210 | } 211 | 212 | return "", fmt.Errorf("no file %s found in %s (search path was: %s)", manifest.FileName, repositoryRoot, strings.Join(docsRootSearchPaths, ", ")) 213 | } 214 | 215 | func buildDocumentation(branches []string, versionsInfo types.VersionsInformation, 216 | fallbackDockerfile docker.DockerfileInformation, menuTemplateContent menu.Content, requirementsContent []byte, 217 | config *types.Configuration, 218 | ) error { 219 | err := addEditionURI(config, versionsInfo) 220 | if err != nil { 221 | return err 222 | } 223 | 224 | err = menu.Build(versionsInfo, branches, menuTemplateContent) 225 | if err != nil { 226 | return fmt.Errorf("failed to build the menu: %w", err) 227 | } 228 | 229 | err = requirements.Build(versionsInfo, requirementsContent) 230 | if err != nil { 231 | return fmt.Errorf("failed to build the requirements: %w", err) 232 | } 233 | 234 | baseDockerfile, err := docker.GetDockerfile(versionsInfo.CurrentPath, fallbackDockerfile, config.DockerfileName) 235 | if err != nil { 236 | return fmt.Errorf("failed to get Dockerfile: %w", err) 237 | } 238 | 239 | dockerImageFullName, err := baseDockerfile.BuildImage(versionsInfo, config.NoCache, config.Debug) 240 | if err != nil { 241 | return fmt.Errorf("failed to build Docker image: %w", err) 242 | } 243 | 244 | args := []string{"run", "--rm", "-v", versionsInfo.CurrentPath + ":/mkdocs"} 245 | if _, err = os.Stat(filepath.Join(versionsInfo.CurrentPath, ".env")); err == nil { 246 | args = append(args, fmt.Sprintf("--env-file=%s", filepath.Join(versionsInfo.CurrentPath, ".env"))) 247 | } 248 | args = append(args, dockerImageFullName, "mkdocs", "build") 249 | 250 | // Run image 251 | output, err := docker.Exec(config.Debug, args...) 252 | if err != nil { 253 | log.Println(output) 254 | return fmt.Errorf("failed to run Docker image: %w", err) 255 | } 256 | 257 | return nil 258 | } 259 | 260 | func addEditionURI(config *types.Configuration, versionsInfo types.VersionsInformation) error { 261 | if !config.ForceEditionURI { 262 | return nil 263 | } 264 | 265 | manifestFile := filepath.Join(versionsInfo.CurrentPath, manifest.FileName) 266 | 267 | manif, err := manifest.Read(manifestFile) 268 | if err != nil { 269 | return fmt.Errorf("failed to read manifest: %w", err) 270 | } 271 | 272 | docsDirSuffix := getDocsDirSuffix(versionsInfo) 273 | 274 | manifest.AddEditionURI(manif, versionsInfo.Current, docsDirSuffix, true) 275 | 276 | return manifest.Write(manifestFile, manif) 277 | } 278 | 279 | func getDocsDirSuffix(versionsInfo types.VersionsInformation) string { 280 | parts := strings.SplitN(versionsInfo.CurrentPath, string(filepath.Separator)+versionsInfo.Current+string(filepath.Separator), 2) 281 | if len(parts) <= 1 { 282 | return "" 283 | } 284 | 285 | return path.Clean(strings.ReplaceAll(parts[1], string(filepath.Separator), "/")) 286 | } 287 | 288 | // copyVersionSiteToOutputSite adds the generated documentation for the version described in ${versionsInfo} to the output directory. 289 | // If the current version (branch) name is related to the latest tag, then it's copied at the root of the output directory. 290 | // Else it is copied under a directory named after the version, at the root of the output directory. 291 | func copyVersionSiteToOutputSite(versionsInfo types.VersionsInformation, siteDir string) error { 292 | currentSiteDir, err := getDocumentationRoot(versionsInfo.CurrentPath) 293 | if err != nil { 294 | return err 295 | } 296 | 297 | outputDir := filepath.Join(siteDir, versionsInfo.Current) 298 | if strings.HasPrefix(versionsInfo.Latest, versionsInfo.Current+".") { 299 | // Create a permalink for the latest version 300 | err := file.Copy(filepath.Join(currentSiteDir, "site"), outputDir) 301 | if err != nil { 302 | return fmt.Errorf("failed to create permalink for the latest version: %w", err) 303 | } 304 | 305 | outputDir = siteDir 306 | } 307 | 308 | return file.Copy(filepath.Join(currentSiteDir, "site"), outputDir) 309 | } 310 | 311 | func cleanAll(workDir string, debug bool) error { 312 | output, err := git.Worktree(worktree.Prune, git.Debugger(debug)) 313 | if err != nil { 314 | log.Println(output) 315 | return fmt.Errorf("failed to prune worktree: %w", err) 316 | } 317 | 318 | return os.RemoveAll(workDir) 319 | } 320 | -------------------------------------------------------------------------------- /core/core_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/ldez/go-git-cmd-wrapper/git" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "github.com/traefik/structor/types" 14 | ) 15 | 16 | func Test_getLatestReleaseTagName(t *testing.T) { 17 | testCases := []struct { 18 | desc string 19 | owner, repositoryName string 20 | envVarLatestTag string 21 | expected string 22 | }{ 23 | { 24 | desc: "without env var override", 25 | owner: "traefik", 26 | repositoryName: "structor", 27 | expected: `v\d+.\d+(.\d+)?`, 28 | }, 29 | { 30 | desc: "with env var override", 31 | owner: "traefik", 32 | repositoryName: "structor", 33 | envVarLatestTag: "foo", 34 | expected: "foo", 35 | }, 36 | } 37 | 38 | for _, test := range testCases { 39 | test := test 40 | t.Run(test.desc, func(t *testing.T) { 41 | t.Setenv(envVarLatestTag, test.envVarLatestTag) 42 | 43 | tagName, err := getLatestReleaseTagName(test.owner, test.repositoryName) 44 | require.NoError(t, err) 45 | 46 | assert.Regexp(t, test.expected, tagName) 47 | }) 48 | } 49 | } 50 | 51 | func Test_getDocumentationRoot(t *testing.T) { 52 | workingDirBasePath, err := os.MkdirTemp("", "structor-test") 53 | defer func() { _ = os.RemoveAll(workingDirBasePath) }() 54 | require.NoError(t, err) 55 | 56 | type expected struct { 57 | docsRoot string 58 | error string 59 | } 60 | 61 | testCases := []struct { 62 | desc string 63 | workingDirectory string 64 | repositoryFiles []string 65 | expected expected 66 | }{ 67 | { 68 | desc: "working case with mkdocs in the root of the repository", 69 | workingDirectory: filepath.Join(workingDirBasePath, "mkdocs-in-root"), 70 | repositoryFiles: []string{"mkdocs.yml", "requirements.txt", "docs.Dockerfile", ".gitignore", "docs/index.md", ".github/ISSUE.md"}, 71 | expected: expected{ 72 | docsRoot: filepath.Join(workingDirBasePath, "mkdocs-in-root"), 73 | }, 74 | }, 75 | { 76 | desc: "working case with mkdocs in ./docs", 77 | workingDirectory: filepath.Join(workingDirBasePath, "mkdocs-in-docs"), 78 | repositoryFiles: []string{"docs/mkdocs.yml", "docs/requirements.txt", "docs/docs.Dockerfile", ".gitignore", "docs/index.md", ".github/ISSUE.md"}, 79 | expected: expected{ 80 | docsRoot: filepath.Join(workingDirBasePath, "mkdocs-in-docs", "docs"), 81 | }, 82 | }, 83 | { 84 | desc: "error case with no mkdocs file found in the search path", 85 | workingDirectory: filepath.Join(workingDirBasePath, "no-mkdocs-in-search-path"), 86 | repositoryFiles: []string{"documentation/mkdocs.yml", "documentation/requirements.txt", "documentation/docs.Dockerfile", ".gitignore", "docs/index.md", ".github/ISSUE.md"}, 87 | expected: expected{ 88 | error: "no file mkdocs.yml found in " + workingDirBasePath + "/no-mkdocs-in-search-path (search path was: /, docs/)", 89 | }, 90 | }, 91 | { 92 | desc: "error case with no mkdocs file found at all", 93 | workingDirectory: filepath.Join(workingDirBasePath, "no-mkdocs-at-all"), 94 | repositoryFiles: []string{"docs/requirements.txt", "docs/docs.Dockerfile", ".gitignore", "docs/index.md", ".github/ISSUE.md"}, 95 | expected: expected{ 96 | error: "no file mkdocs.yml found in " + workingDirBasePath + "/no-mkdocs-at-all (search path was: /, docs/)", 97 | }, 98 | }, 99 | } 100 | 101 | for _, test := range testCases { 102 | test := test 103 | t.Run(test.desc, func(t *testing.T) { 104 | if test.workingDirectory != "" { 105 | err = os.MkdirAll(test.workingDirectory, os.ModePerm) 106 | require.NoError(t, err) 107 | } 108 | 109 | if test.repositoryFiles != nil { 110 | for _, repositoryFile := range test.repositoryFiles { 111 | absoluteRepositoryFilePath := filepath.Join(test.workingDirectory, repositoryFile) 112 | err := os.MkdirAll(filepath.Dir(absoluteRepositoryFilePath), os.ModePerm) 113 | require.NoError(t, err) 114 | _, err = os.Create(absoluteRepositoryFilePath) 115 | require.NoError(t, err) 116 | } 117 | } 118 | 119 | docsRoot, err := getDocumentationRoot(test.workingDirectory) 120 | 121 | if test.expected.error != "" { 122 | assert.EqualError(t, err, test.expected.error) 123 | } else { 124 | require.NoError(t, err) 125 | assert.Equal(t, test.expected.docsRoot, docsRoot) 126 | } 127 | }) 128 | } 129 | } 130 | 131 | func Test_getDocsDirSuffix(t *testing.T) { 132 | testCases := []struct { 133 | desc string 134 | version types.VersionsInformation 135 | expected string 136 | }{ 137 | { 138 | desc: "no suffix", 139 | version: types.VersionsInformation{ 140 | CurrentPath: "/tmp/structor694968263/v2.0", 141 | Current: "v2.0", 142 | }, 143 | expected: "", 144 | }, 145 | { 146 | desc: "simple suffix", 147 | version: types.VersionsInformation{ 148 | CurrentPath: "/tmp/structor694968263/v2.0/docs", 149 | Current: "v2.0", 150 | }, 151 | expected: "docs", 152 | }, 153 | { 154 | desc: "suffix with slash", 155 | version: types.VersionsInformation{ 156 | CurrentPath: "/tmp/structor694968263/v2.0/docs/", 157 | Current: "v2.0", 158 | }, 159 | expected: "docs", 160 | }, 161 | { 162 | desc: "long suffix", 163 | version: types.VersionsInformation{ 164 | CurrentPath: "/tmp/structor694968263/v2.0/docs/foo", 165 | Current: "v2.0", 166 | }, 167 | expected: "docs/foo", 168 | }, 169 | { 170 | desc: "contains two times the version path", 171 | version: types.VersionsInformation{ 172 | CurrentPath: "/tmp/structor694968263/v2.0/docs/foo/v2.0/bar", 173 | Current: "v2.0", 174 | }, 175 | expected: "docs/foo/v2.0/bar", 176 | }, 177 | } 178 | 179 | for _, test := range testCases { 180 | test := test 181 | t.Run(test.desc, func(t *testing.T) { 182 | t.Parallel() 183 | 184 | suffix := getDocsDirSuffix(test.version) 185 | 186 | assert.Equal(t, test.expected, suffix) 187 | }) 188 | } 189 | } 190 | 191 | func Test_createDirectory(t *testing.T) { 192 | dir, err := os.MkdirTemp("", "structor-test") 193 | require.NoError(t, err) 194 | defer func() { _ = os.RemoveAll(dir) }() 195 | 196 | err = os.MkdirAll(filepath.Join(dir, "existing"), os.ModePerm) 197 | require.NoError(t, err) 198 | 199 | testCases := []struct { 200 | desc string 201 | dirPath string 202 | }{ 203 | { 204 | desc: "when a directory doesn't already exists", 205 | dirPath: filepath.Join(dir, "here"), 206 | }, 207 | { 208 | desc: "when a directory already exists", 209 | dirPath: filepath.Join(dir, "existing"), 210 | }, 211 | } 212 | 213 | for _, test := range testCases { 214 | test := test 215 | t.Run(test.desc, func(t *testing.T) { 216 | err := createDirectory(test.dirPath) 217 | require.NoError(t, err) 218 | 219 | assert.DirExists(t, test.dirPath) 220 | }) 221 | } 222 | } 223 | 224 | func Test_getBranches(t *testing.T) { 225 | git.CmdExecutor = func(name string, debug bool, args ...string) (string, error) { 226 | if debug { 227 | log.Println(name, strings.Join(args, " ")) 228 | } 229 | return ` 230 | origin/v1.3 231 | origin/v1.1 232 | origin/v1.2 233 | `, nil 234 | } 235 | 236 | testCases := []struct { 237 | desc string 238 | experimentalBranchName string 239 | excludedBranches []string 240 | expected []string 241 | }{ 242 | { 243 | desc: "all existing branches", 244 | expected: []string{ 245 | "origin/v1.3", 246 | "origin/v1.2", 247 | "origin/v1.1", 248 | }, 249 | }, 250 | { 251 | desc: "add experimental branch", 252 | experimentalBranchName: "master", 253 | expected: []string{ 254 | "origin/master", 255 | "origin/v1.3", 256 | "origin/v1.2", 257 | "origin/v1.1", 258 | }, 259 | }, 260 | { 261 | desc: "exclude one branch", 262 | excludedBranches: []string{"v1.1"}, 263 | expected: []string{ 264 | "origin/v1.3", 265 | "origin/v1.2", 266 | }, 267 | }, 268 | { 269 | desc: "exclude all branches", 270 | excludedBranches: []string{"v1.1", "v1.2", "v1.3"}, 271 | expected: nil, 272 | }, 273 | } 274 | 275 | for _, test := range testCases { 276 | test := test 277 | t.Run(test.desc, func(t *testing.T) { 278 | t.Parallel() 279 | 280 | branches, err := getBranches(test.experimentalBranchName, test.excludedBranches, true) 281 | require.NoError(t, err) 282 | 283 | assert.Equal(t, test.expected, branches) 284 | }) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /docker/docker.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/traefik/structor/file" 15 | "github.com/traefik/structor/types" 16 | ) 17 | 18 | // DockerfileInformation Dockerfile information. 19 | type DockerfileInformation struct { 20 | Name string 21 | Path string 22 | Content []byte 23 | ImageName string 24 | dryRun bool 25 | } 26 | 27 | // BuildImage Builds a Docker image. 28 | func (d *DockerfileInformation) BuildImage(versionsInfo types.VersionsInformation, noCache, debug bool) (string, error) { 29 | err := os.WriteFile(d.Path, d.Content, os.ModePerm) 30 | if err != nil { 31 | return "", fmt.Errorf("failed to write Docker file: %w", err) 32 | } 33 | 34 | dockerImageFullName := buildImageFullName(d.ImageName, versionsInfo.Current) 35 | 36 | // Build image 37 | output, err := execCmd(d.dryRun, debug, "build", "--no-cache="+strconv.FormatBool(noCache), "-t", dockerImageFullName, "-f", d.Path, versionsInfo.CurrentPath+"/") 38 | if err != nil { 39 | log.Println(output) 40 | return "", fmt.Errorf("failed to build Docker image %s (path: %s, current path: %s): %w", dockerImageFullName, d.Path, versionsInfo.CurrentPath, err) 41 | } 42 | 43 | return dockerImageFullName, nil 44 | } 45 | 46 | // buildImageFullName returns the full docker image name, in the form image:tag. 47 | // Please note that normalization is applied to avoid forbidden characters. 48 | func buildImageFullName(imageName, tagName string) string { 49 | r := strings.NewReplacer(":", "-", "/", "-") 50 | return r.Replace(imageName) + ":" + r.Replace(tagName) 51 | } 52 | 53 | // GetDockerfileFallback Downloads and creates the DockerfileInformation of the Dockerfile fallback. 54 | func GetDockerfileFallback(dockerfileURL, imageName string) (DockerfileInformation, error) { 55 | dockerFileContent, err := file.Download(dockerfileURL) 56 | if err != nil { 57 | return DockerfileInformation{}, fmt.Errorf("failed to download Dockerfile: %w", err) 58 | } 59 | 60 | return DockerfileInformation{ 61 | Name: fmt.Sprintf("%v.Dockerfile", time.Now().UnixNano()), 62 | Content: dockerFileContent, 63 | ImageName: imageName, 64 | }, nil 65 | } 66 | 67 | // GetDockerfile Gets the effective Dockerfile. 68 | func GetDockerfile(workingDirectory string, fallbackDockerfile DockerfileInformation, dockerfileName string) (*DockerfileInformation, error) { 69 | if workingDirectory == "" { 70 | return nil, errors.New("workingDirectory is undefined") 71 | } 72 | if _, err := os.Stat(workingDirectory); err != nil { 73 | return nil, err 74 | } 75 | 76 | searchPaths := []string{ 77 | filepath.Join(workingDirectory, dockerfileName), 78 | filepath.Join(workingDirectory, "docs", dockerfileName), 79 | } 80 | 81 | for _, searchPath := range searchPaths { 82 | if _, err := os.Stat(searchPath); err != nil { 83 | continue 84 | } 85 | 86 | log.Printf("Found Dockerfile for building documentation in %s.", searchPath) 87 | 88 | dockerFileContent, err := os.ReadFile(searchPath) 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to get dockerfile file content: %w", err) 91 | } 92 | 93 | return &DockerfileInformation{ 94 | Name: dockerfileName, 95 | Path: searchPath, 96 | ImageName: fallbackDockerfile.ImageName, 97 | Content: dockerFileContent, 98 | }, nil 99 | } 100 | 101 | log.Printf("Using fallback Dockerfile, written into %s", fallbackDockerfile.Path) 102 | return &fallbackDockerfile, nil 103 | } 104 | 105 | // Exec Executes a docker command. 106 | func Exec(debug bool, args ...string) (string, error) { 107 | return execCmd(false, debug, args...) 108 | } 109 | 110 | func execCmd(dryRun, debug bool, args ...string) (string, error) { 111 | cmdName := "docker" 112 | 113 | if debug || dryRun { 114 | log.Println(cmdName, strings.Join(args, " ")) 115 | } 116 | 117 | if dryRun { 118 | return "", nil 119 | } 120 | 121 | output, err := exec.Command(cmdName, args...).CombinedOutput() 122 | 123 | return string(output), err 124 | } 125 | -------------------------------------------------------------------------------- /docker/docker_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/traefik/structor/types" 13 | ) 14 | 15 | func TestDockerfileInformation_BuildImage(t *testing.T) { 16 | dir, err := os.MkdirTemp("", "structor-test") 17 | require.NoError(t, err) 18 | defer func() { _ = os.RemoveAll(dir) }() 19 | 20 | versionsInfo := types.VersionsInformation{ 21 | Current: "v1.1", 22 | Latest: "v1.1", 23 | Experimental: "master", 24 | CurrentPath: "/here", 25 | } 26 | 27 | info := &DockerfileInformation{ 28 | Name: "1234.Dockerfile", 29 | Path: filepath.Join(dir, "sample.Dockerfile"), 30 | Content: mustReadFile("./fixtures/docs.Dockerfile"), 31 | ImageName: "project", 32 | dryRun: true, 33 | } 34 | 35 | image, err := info.BuildImage(versionsInfo, false, false) 36 | require.NoError(t, err) 37 | 38 | assert.Equal(t, "project:v1.1", image) 39 | } 40 | 41 | func Test_buildImageFullName(t *testing.T) { 42 | testCases := []struct { 43 | desc string 44 | imageName string 45 | tagName string 46 | expectedFullName string 47 | }{ 48 | { 49 | desc: "image and tag with no special chars", 50 | imageName: "debian", 51 | tagName: "slim", 52 | expectedFullName: "debian:slim", 53 | }, 54 | { 55 | desc: "image and tag with dashes", 56 | imageName: "open-jdk", 57 | tagName: "8-alpine", 58 | expectedFullName: "open-jdk:8-alpine", 59 | }, 60 | { 61 | desc: "image with no special chars and tag with slashes", 62 | imageName: "structor", 63 | tagName: "feature/antenna", 64 | expectedFullName: "structor:feature-antenna", 65 | }, 66 | { 67 | desc: "image with slashes and tag with no special chars", 68 | imageName: "structor/feature", 69 | tagName: "antenna", 70 | expectedFullName: "structor-feature:antenna", 71 | }, 72 | { 73 | desc: "image with column and tag with no special chars", 74 | imageName: "structor:feature", 75 | tagName: "antenna", 76 | expectedFullName: "structor-feature:antenna", 77 | }, 78 | { 79 | desc: "image with no special chars and tag with column", 80 | imageName: "structor", 81 | tagName: "feature:antenna", 82 | expectedFullName: "structor:feature-antenna", 83 | }, 84 | { 85 | desc: "Mix of everything: slashes and columns", 86 | imageName: "struct:or/feat", 87 | tagName: "ant/en:na", 88 | expectedFullName: "struct-or-feat:ant-en-na", 89 | }, 90 | } 91 | 92 | for _, test := range testCases { 93 | test := test 94 | t.Run(test.desc, func(t *testing.T) { 95 | t.Parallel() 96 | 97 | dockerFullImageName := buildImageFullName(test.imageName, test.tagName) 98 | assert.Equal(t, dockerFullImageName, test.expectedFullName) 99 | }) 100 | } 101 | } 102 | 103 | func TestGetDockerfile(t *testing.T) { 104 | workingDirBasePath, err := os.MkdirTemp("", "structor-test") 105 | defer func() { _ = os.RemoveAll(workingDirBasePath) }() 106 | require.NoError(t, err) 107 | 108 | fallbackDockerfile := DockerfileInformation{ 109 | Name: "fallback.Dockerfile", 110 | ImageName: "mycompany/backend:1.2.1", 111 | Path: filepath.Join(workingDirBasePath, "fallback"), 112 | Content: []byte("FROM alpine:3.8"), 113 | } 114 | 115 | testCases := []struct { 116 | desc string 117 | workingDirectory string 118 | dockerfilePath string 119 | dockerfileContent string 120 | dockerfileName string 121 | expectedDockerfile *DockerfileInformation 122 | expectedErrorMessage string 123 | }{ 124 | { 125 | desc: "normal case with a docs.Dockerfile at the root", 126 | workingDirectory: filepath.Join(workingDirBasePath, "normal"), 127 | dockerfilePath: filepath.Join(workingDirBasePath, "normal", "docs.Dockerfile"), 128 | dockerfileContent: "FROM alpine:3.8\n", 129 | dockerfileName: "docs.Dockerfile", 130 | expectedDockerfile: &DockerfileInformation{ 131 | Name: "docs.Dockerfile", 132 | ImageName: "mycompany/backend:1.2.1", 133 | Path: filepath.Join(workingDirBasePath, "normal", "docs.Dockerfile"), 134 | Content: []byte("FROM alpine:3.8\n"), 135 | }, 136 | expectedErrorMessage: "", 137 | }, 138 | { 139 | desc: "normal case with a docs.Dockerfile in the docs directory", 140 | workingDirectory: filepath.Join(workingDirBasePath, "normal-docs"), 141 | dockerfilePath: filepath.Join(workingDirBasePath, "normal-docs", "docs", "docs.Dockerfile"), 142 | dockerfileContent: "FROM alpine:3.8\n", 143 | dockerfileName: "docs.Dockerfile", 144 | expectedDockerfile: &DockerfileInformation{ 145 | Name: "docs.Dockerfile", 146 | ImageName: "mycompany/backend:1.2.1", 147 | Path: filepath.Join(workingDirBasePath, "normal-docs", "docs", "docs.Dockerfile"), 148 | Content: []byte("FROM alpine:3.8\n"), 149 | }, 150 | expectedErrorMessage: "", 151 | }, 152 | { 153 | desc: "normal case with no docs.Dockerfile found", 154 | workingDirectory: filepath.Join(workingDirBasePath, "normal-no-dockerfile-found"), 155 | dockerfilePath: "", 156 | dockerfileContent: "FROM alpine:3.8\n", 157 | dockerfileName: "docs.Dockerfile", 158 | expectedDockerfile: &fallbackDockerfile, 159 | expectedErrorMessage: "", 160 | }, 161 | { 162 | desc: "error case with workingDirectory undefined", 163 | workingDirectory: "", 164 | dockerfilePath: filepath.Join(workingDirBasePath, "error-workingDirectory-undefined", "docs.Dockerfile"), 165 | dockerfileContent: "FROM alpine:3.8\n", 166 | dockerfileName: "docs.Dockerfile", 167 | expectedDockerfile: nil, 168 | expectedErrorMessage: "workingDirectory is undefined", 169 | }, 170 | { 171 | desc: "error case with workingDirectory not found", 172 | workingDirectory: "not-existing", 173 | dockerfilePath: filepath.Join(workingDirBasePath, "error-workingDirectory-not-found", "docs.Dockerfile"), 174 | dockerfileContent: "FROM alpine:3.8\n", 175 | dockerfileName: "docs.Dockerfile", 176 | expectedDockerfile: nil, 177 | expectedErrorMessage: "stat not-existing: no such file or directory", 178 | }, 179 | } 180 | 181 | for _, test := range testCases { 182 | test := test 183 | t.Run(test.desc, func(t *testing.T) { 184 | if test.workingDirectory != "" && filepath.IsAbs(test.workingDirectory) { 185 | err = os.MkdirAll(test.workingDirectory, os.ModePerm) 186 | require.NoError(t, err) 187 | } 188 | 189 | if test.dockerfilePath != "" { 190 | err = os.MkdirAll(filepath.Dir(test.dockerfilePath), os.ModePerm) 191 | require.NoError(t, err) 192 | if test.dockerfileContent != "" { 193 | err = os.WriteFile(test.dockerfilePath, []byte(test.dockerfileContent), os.ModePerm) 194 | require.NoError(t, err) 195 | } 196 | } 197 | 198 | resultingDockerfile, resultingError := GetDockerfile(test.workingDirectory, fallbackDockerfile, test.dockerfileName) 199 | 200 | if test.expectedErrorMessage != "" { 201 | assert.EqualError(t, resultingError, test.expectedErrorMessage) 202 | } else { 203 | require.NoError(t, resultingError) 204 | assert.Equal(t, test.expectedDockerfile, resultingDockerfile) 205 | } 206 | }) 207 | } 208 | } 209 | 210 | func TestGetDockerfileFallback(t *testing.T) { 211 | serverURL, teardown := serveFixturesContent() 212 | defer teardown() 213 | 214 | dockerFileURL := serverURL + "/docs.Dockerfile" 215 | imageName := "test" 216 | 217 | info, err := GetDockerfileFallback(dockerFileURL, imageName) 218 | require.NoError(t, err) 219 | 220 | assert.Regexp(t, `\d{12,}.Dockerfile`, info.Name) 221 | assert.Empty(t, info.Path) 222 | assert.Equal(t, mustReadFile("./fixtures/docs.Dockerfile"), info.Content) 223 | assert.Equal(t, "test", info.ImageName) 224 | } 225 | 226 | func mustReadFile(path string) []byte { 227 | bytes, err := os.ReadFile(path) 228 | if err != nil { 229 | panic(err) 230 | } 231 | return bytes 232 | } 233 | 234 | func serveFixturesContent() (string, func()) { 235 | mux := http.NewServeMux() 236 | mux.Handle("/", http.FileServer(http.Dir("./fixtures/"))) 237 | 238 | server := httptest.NewServer(mux) 239 | 240 | return server.URL, server.Close 241 | } 242 | -------------------------------------------------------------------------------- /docker/fixtures/docs.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.7 2 | 3 | ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin 4 | 5 | COPY requirements.txt /mkdocs/ 6 | WORKDIR /mkdocs 7 | VOLUME /mkdocs 8 | 9 | RUN apk --no-cache --no-progress add py-pip \ 10 | && pip install --user -r requirements.txt 11 | -------------------------------------------------------------------------------- /file/copy.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // Copy copies src to dst. 11 | func Copy(src, dst string) error { 12 | info, err := os.Stat(src) 13 | if err != nil { 14 | return err 15 | } 16 | return tryCopy(src, dst, info) 17 | } 18 | 19 | func tryCopy(src, dst string, info os.FileInfo) error { 20 | if info.IsDir() { 21 | return directoryCopy(src, dst, info) 22 | } 23 | return fileCopy(src, dst, info) 24 | } 25 | 26 | func fileCopy(src, dst string, info os.FileInfo) error { 27 | f, err := os.Create(dst) 28 | if err != nil { 29 | return err 30 | } 31 | defer safeClose(f.Close) 32 | 33 | if err = os.Chmod(f.Name(), info.Mode()); err != nil { 34 | return err 35 | } 36 | 37 | s, err := os.Open(src) 38 | if err != nil { 39 | return err 40 | } 41 | defer safeClose(s.Close) 42 | 43 | _, err = io.Copy(f, s) 44 | return err 45 | } 46 | 47 | func directoryCopy(src, dst string, info os.FileInfo) error { 48 | if err := os.MkdirAll(dst, info.Mode()); err != nil { 49 | return err 50 | } 51 | 52 | dirEntries, err := os.ReadDir(src) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | for _, entry := range dirEntries { 58 | info, err := entry.Info() 59 | if err != nil { 60 | return err 61 | } 62 | 63 | err = tryCopy(filepath.Join(src, info.Name()), filepath.Join(dst, info.Name()), info) 64 | if err != nil { 65 | return err 66 | } 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func safeClose(fn func() error) { 73 | if err := fn(); err != nil { 74 | log.Println(err) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /file/download.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | // Download Downloads a file. 10 | func Download(url string) ([]byte, error) { 11 | resp, err := http.Get(url) 12 | if err != nil { 13 | return nil, fmt.Errorf("failed to download: %w", err) 14 | } 15 | 16 | defer func() { _ = resp.Body.Close() }() 17 | 18 | if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { 19 | return nil, fmt.Errorf("failed to download %q: %s", url, resp.Status) 20 | } 21 | 22 | return io.ReadAll(resp.Body) 23 | } 24 | -------------------------------------------------------------------------------- /file/download_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestDownload(t *testing.T) { 15 | serverURL, teardown := serveFixturesContent() 16 | defer teardown() 17 | 18 | uri := serverURL + "/data.txt" 19 | 20 | bytes, err := Download(uri) 21 | require.NoError(t, err) 22 | 23 | assert.Equal(t, mustReadFile(filepath.Join(".", "fixtures", "data.txt")), bytes) 24 | } 25 | 26 | func TestDownload_Fail(t *testing.T) { 27 | serverURL, teardown := serveFixturesContent() 28 | defer teardown() 29 | 30 | uri := serverURL + "/missing.txt" 31 | 32 | _, err := Download(uri) 33 | 34 | require.Error(t, err) 35 | assert.Regexp(t, `failed to download "http://127.0.0.1:\d+/missing.txt": 404 Not Found`, err.Error()) 36 | } 37 | 38 | func mustReadFile(path string) []byte { 39 | bytes, err := os.ReadFile(path) 40 | if err != nil { 41 | panic(err) 42 | } 43 | return bytes 44 | } 45 | 46 | func serveFixturesContent() (string, func()) { 47 | mux := http.NewServeMux() 48 | mux.Handle("/", http.FileServer(http.Dir("./fixtures"))) 49 | 50 | server := httptest.NewServer(mux) 51 | 52 | return server.URL, server.Close 53 | } 54 | -------------------------------------------------------------------------------- /file/fixtures/data.txt: -------------------------------------------------------------------------------- 1 | Googoo gaga gaga da gaagaa doo laa ga gaga laalaa gaga goo. 2 | Gaa doo ya dum-dum gaa. Googoo yaya gaagaa gaagaa googoo gaga goo. 3 | Gaagaa gaa doo ga gaga. Doo doo yaya goo googoo ga da googoo da. Gaa doo ya dum-dum gaa. Gaa doo ya dum-dum gaa. 4 | Milkie gaga caca goo ga laa didee puffer googoo. Milkie gaga caca goo ga laa didee puffer googoo. 5 | -------------------------------------------------------------------------------- /gh/gh.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | // GetLatestReleaseTagName find the latest release tag name. 12 | func GetLatestReleaseTagName(owner, repositoryName string) (string, error) { 13 | client := &http.Client{ 14 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 15 | return http.ErrUseLastResponse 16 | }, 17 | } 18 | 19 | baseURL := fmt.Sprintf("https://github.com/%s/%s/releases", owner, repositoryName) 20 | 21 | resp, err := client.Get(baseURL + "/latest") 22 | defer func() { _ = resp.Body.Close() }() 23 | if err != nil { 24 | logResponseBody(resp) 25 | return "", fmt.Errorf("failed to get latest release tag name: %w", err) 26 | } 27 | 28 | if resp.StatusCode >= http.StatusBadRequest { 29 | logResponseBody(resp) 30 | return "", fmt.Errorf("failed to get latest release tag name on GitHub (%q), status: %s", baseURL+"/latest", resp.Status) 31 | } 32 | 33 | location, err := resp.Location() 34 | if err != nil { 35 | return "", fmt.Errorf("failed to get location header: %w", err) 36 | } 37 | 38 | return strings.TrimPrefix(location.String(), baseURL+"/tag/"), nil 39 | } 40 | 41 | func logResponseBody(resp *http.Response) { 42 | if resp.Body == nil { 43 | log.Println("The response body is empty") 44 | return 45 | } 46 | 47 | body, errBody := io.ReadAll(resp.Body) 48 | if errBody != nil { 49 | log.Println(errBody) 50 | return 51 | } 52 | 53 | log.Println("Body:", string(body)) 54 | } 55 | -------------------------------------------------------------------------------- /gh/gh_test.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGetLatestReleaseTagName(t *testing.T) { 11 | tagName, err := GetLatestReleaseTagName("traefik", "structor") 12 | 13 | require.NoError(t, err) 14 | assert.Regexp(t, `v\d+.\d+(.\d+)?`, tagName) 15 | } 16 | 17 | func TestGetLatestReleaseTagName_Errors(t *testing.T) { 18 | _, err := GetLatestReleaseTagName("error", "error") 19 | 20 | assert.EqualError(t, err, `failed to get latest release tag name on GitHub ("https://github.com/error/error/releases/latest"), status: 404 Not Found`) 21 | } 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/traefik/structor 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.2.3 7 | github.com/hashicorp/go-version v1.6.0 8 | github.com/ldez/go-git-cmd-wrapper v0.22.0 9 | github.com/spf13/cobra v1.6.1 10 | github.com/stretchr/testify v1.8.1 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/Masterminds/goutils v1.1.1 // indirect 16 | github.com/Masterminds/semver/v3 v3.2.0 // indirect 17 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/google/uuid v1.1.2 // indirect 20 | github.com/huandu/xstrings v1.3.3 // indirect 21 | github.com/imdario/mergo v0.3.11 // indirect 22 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 23 | github.com/mitchellh/copystructure v1.2.0 // indirect 24 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 27 | github.com/shopspring/decimal v1.3.1 // indirect 28 | github.com/spf13/cast v1.5.0 // indirect 29 | github.com/spf13/pflag v1.0.5 // indirect 30 | golang.org/x/crypto v0.6.0 // indirect 31 | gopkg.in/yaml.v2 v2.4.0 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 2 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 3 | github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= 4 | github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 5 | github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= 6 | github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 8 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= 13 | github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= 14 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 15 | github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= 16 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= 18 | github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 19 | github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= 20 | github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 21 | github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= 22 | github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 23 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 24 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 25 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 26 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 27 | github.com/ldez/go-git-cmd-wrapper v0.22.0 h1:KA+5InHdUFO+7EPIavVlvWVdZwOAxoofNNpSCSs6fiU= 28 | github.com/ldez/go-git-cmd-wrapper v0.22.0/go.mod h1:jpAfzzx/p68/6Z9d85d75KEFv/Nci8DIVzx4xHHsTwo= 29 | github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= 30 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 31 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 32 | github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 33 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 34 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 35 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 36 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 37 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= 38 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 39 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 40 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 41 | github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= 42 | github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 43 | github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 44 | github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= 45 | github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= 46 | github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= 47 | github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= 48 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 49 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 50 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 51 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 52 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 53 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 54 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 55 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 56 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 57 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 58 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 59 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 60 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 61 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 62 | golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= 63 | golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= 64 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 65 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 66 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 67 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 68 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 69 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 70 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 71 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 72 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 73 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 74 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 76 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 77 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 79 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 80 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 81 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 82 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 83 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 84 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 85 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 86 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 87 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 88 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 89 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 90 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 92 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 93 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 94 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 95 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 96 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 97 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 98 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 99 | -------------------------------------------------------------------------------- /godownloader.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Code generated by godownloader on 2019-02-20T10:05:28Z. DO NOT EDIT. 4 | # 5 | 6 | usage() { 7 | this=$1 8 | cat </dev/null 137 | } 138 | echoerr() { 139 | echo "$@" 1>&2 140 | } 141 | log_prefix() { 142 | echo "$0" 143 | } 144 | _logp=6 145 | log_set_priority() { 146 | _logp="$1" 147 | } 148 | log_priority() { 149 | if test -z "$1"; then 150 | echo "$_logp" 151 | return 152 | fi 153 | [ "$1" -le "$_logp" ] 154 | } 155 | log_tag() { 156 | case $1 in 157 | 0) echo "emerg" ;; 158 | 1) echo "alert" ;; 159 | 2) echo "crit" ;; 160 | 3) echo "err" ;; 161 | 4) echo "warning" ;; 162 | 5) echo "notice" ;; 163 | 6) echo "info" ;; 164 | 7) echo "debug" ;; 165 | *) echo "$1" ;; 166 | esac 167 | } 168 | log_debug() { 169 | log_priority 7 || return 0 170 | echoerr "$(log_prefix)" "$(log_tag 7)" "$@" 171 | } 172 | log_info() { 173 | log_priority 6 || return 0 174 | echoerr "$(log_prefix)" "$(log_tag 6)" "$@" 175 | } 176 | log_err() { 177 | log_priority 3 || return 0 178 | echoerr "$(log_prefix)" "$(log_tag 3)" "$@" 179 | } 180 | log_crit() { 181 | log_priority 2 || return 0 182 | echoerr "$(log_prefix)" "$(log_tag 2)" "$@" 183 | } 184 | uname_os() { 185 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 186 | case "$os" in 187 | msys_nt) os="windows" ;; 188 | esac 189 | echo "$os" 190 | } 191 | uname_arch() { 192 | arch=$(uname -m) 193 | case $arch in 194 | x86_64) arch="amd64" ;; 195 | x86) arch="386" ;; 196 | i686) arch="386" ;; 197 | i386) arch="386" ;; 198 | aarch64) arch="arm64" ;; 199 | armv5*) arch="armv5" ;; 200 | armv6*) arch="armv6" ;; 201 | armv7*) arch="armv7" ;; 202 | esac 203 | echo ${arch} 204 | } 205 | uname_os_check() { 206 | os=$(uname_os) 207 | case "$os" in 208 | darwin) return 0 ;; 209 | dragonfly) return 0 ;; 210 | freebsd) return 0 ;; 211 | linux) return 0 ;; 212 | android) return 0 ;; 213 | nacl) return 0 ;; 214 | netbsd) return 0 ;; 215 | openbsd) return 0 ;; 216 | plan9) return 0 ;; 217 | solaris) return 0 ;; 218 | windows) return 0 ;; 219 | esac 220 | log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 221 | return 1 222 | } 223 | uname_arch_check() { 224 | arch=$(uname_arch) 225 | case "$arch" in 226 | 386) return 0 ;; 227 | amd64) return 0 ;; 228 | arm64) return 0 ;; 229 | armv5) return 0 ;; 230 | armv6) return 0 ;; 231 | armv7) return 0 ;; 232 | ppc64) return 0 ;; 233 | ppc64le) return 0 ;; 234 | mips) return 0 ;; 235 | mipsle) return 0 ;; 236 | mips64) return 0 ;; 237 | mips64le) return 0 ;; 238 | s390x) return 0 ;; 239 | amd64p32) return 0 ;; 240 | esac 241 | log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 242 | return 1 243 | } 244 | untar() { 245 | tarball=$1 246 | case "${tarball}" in 247 | *.tar.gz | *.tgz) tar -xzf "${tarball}" ;; 248 | *.tar) tar -xf "${tarball}" ;; 249 | *.zip) unzip "${tarball}" ;; 250 | *) 251 | log_err "untar unknown archive format for ${tarball}" 252 | return 1 253 | ;; 254 | esac 255 | } 256 | mktmpdir() { 257 | test -z "$TMPDIR" && TMPDIR="$(mktemp -d)" 258 | mkdir -p "${TMPDIR}" 259 | echo "${TMPDIR}" 260 | } 261 | http_download_curl() { 262 | local_file=$1 263 | source_url=$2 264 | header=$3 265 | if [ -z "$header" ]; then 266 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 267 | else 268 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 269 | fi 270 | if [ "$code" != "200" ]; then 271 | log_debug "http_download_curl received HTTP status $code" 272 | return 1 273 | fi 274 | return 0 275 | } 276 | http_download_wget() { 277 | local_file=$1 278 | source_url=$2 279 | header=$3 280 | if [ -z "$header" ]; then 281 | wget -q -O "$local_file" "$source_url" 282 | else 283 | wget -q --header "$header" -O "$local_file" "$source_url" 284 | fi 285 | } 286 | http_download() { 287 | log_debug "http_download $2" 288 | if is_command curl; then 289 | http_download_curl "$@" 290 | return 291 | elif is_command wget; then 292 | http_download_wget "$@" 293 | return 294 | fi 295 | log_crit "http_download unable to find wget or curl" 296 | return 1 297 | } 298 | http_copy() { 299 | tmp=$(mktemp) 300 | http_download "${tmp}" "$1" "$2" || return 1 301 | body=$(cat "$tmp") 302 | rm -f "${tmp}" 303 | echo "$body" 304 | } 305 | github_release() { 306 | owner_repo=$1 307 | version=$2 308 | test -z "$version" && version="latest" 309 | giturl="https://github.com/${owner_repo}/releases/${version}" 310 | json=$(http_copy "$giturl" "Accept:application/json") 311 | test -z "$json" && return 1 312 | version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') 313 | test -z "$version" && return 1 314 | echo "$version" 315 | } 316 | hash_sha256() { 317 | TARGET=${1:-/dev/stdin} 318 | if is_command gsha256sum; then 319 | hash=$(gsha256sum "$TARGET") || return 1 320 | echo "$hash" | cut -d ' ' -f 1 321 | elif is_command sha256sum; then 322 | hash=$(sha256sum "$TARGET") || return 1 323 | echo "$hash" | cut -d ' ' -f 1 324 | elif is_command shasum; then 325 | hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 326 | echo "$hash" | cut -d ' ' -f 1 327 | elif is_command openssl; then 328 | hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 329 | echo "$hash" | cut -d ' ' -f a 330 | else 331 | log_crit "hash_sha256 unable to find command to compute sha-256 hash" 332 | return 1 333 | fi 334 | } 335 | hash_sha256_verify() { 336 | TARGET=$1 337 | checksums=$2 338 | if [ -z "$checksums" ]; then 339 | log_err "hash_sha256_verify checksum file not specified in arg2" 340 | return 1 341 | fi 342 | BASENAME=${TARGET##*/} 343 | want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) 344 | if [ -z "$want" ]; then 345 | log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" 346 | return 1 347 | fi 348 | got=$(hash_sha256 "$TARGET") 349 | if [ "$want" != "$got" ]; then 350 | log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" 351 | return 1 352 | fi 353 | } 354 | cat /dev/null < 0 { 94 | var extraJs []interface{} 95 | if val, ok := manif["extra_javascript"]; ok { 96 | extraJs = val.([]interface{}) 97 | } 98 | extraJs = append(extraJs, jsFile) 99 | manif["extra_javascript"] = extraJs 100 | } 101 | } 102 | 103 | // AppendExtraCSS Appends a file path to the "extra_css" in the manifest file. 104 | func AppendExtraCSS(manif map[string]interface{}, cssFile string) { 105 | if len(cssFile) > 0 { 106 | var extraCSS []interface{} 107 | if val, ok := manif["extra_css"]; ok { 108 | extraCSS = val.([]interface{}) 109 | } 110 | extraCSS = append(extraCSS, cssFile) 111 | manif["extra_css"] = extraCSS 112 | } 113 | } 114 | 115 | // AddEditionURI Adds an edition URI to the "edit_uri" in the manifest file. 116 | func AddEditionURI(manif map[string]interface{}, version, docsDirBase string, override bool) { 117 | v := version 118 | if v == "" { 119 | v = "master" 120 | } 121 | 122 | if val, ok := manif["edit_uri"]; ok && len(val.(string)) > 0 && !override { 123 | // noop 124 | return 125 | } 126 | 127 | docsDir := getDocsDirAttribute(manif) 128 | 129 | manif["edit_uri"] = path.Join("edit", v, docsDirBase, docsDir) + "/" 130 | } 131 | -------------------------------------------------------------------------------- /manifest/manifest_test.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestRead(t *testing.T) { 12 | testCases := []struct { 13 | desc string 14 | filename string 15 | expected map[string]interface{} 16 | }{ 17 | { 18 | desc: "with !!python", 19 | filename: "sample-mkdocs.yml", 20 | expected: map[string]interface{}{ 21 | "copyright": "Copyright © 2020 drasyl", 22 | "dev_addr": "0.0.0.0:8000", 23 | "docs_dir": "content", 24 | "edit_uri": "https://git.informatik.uni-hamburg.de/sane-public/drasyl/-/edit/master/docs/content", 25 | "extra": map[string]interface{}{ 26 | "social": []interface{}{ 27 | map[string]interface{}{ 28 | "icon": "fontawesome/brands/github", 29 | "link": "https://github.com/drasyl-overlay/drasyl/", 30 | "name": "GitHub repo of drasyl", 31 | }, 32 | map[string]interface{}{ 33 | "icon": "fontawesome/brands/gitlab", 34 | "link": "https://git.informatik.uni-hamburg.de/sane-public/drasyl", 35 | "name": "GitLab repo of drasyl", 36 | }, 37 | map[string]interface{}{ 38 | "icon": "fontawesome/brands/docker", 39 | "link": "https://hub.docker.com/r/drasyl/drasyl", 40 | "name": "Docker repo of drasyl", 41 | }, 42 | }, 43 | }, 44 | "extra_css": []interface{}{"assets/style/content.css", "assets/style/atom-one-light.css"}, 45 | "extra_javascript": []interface{}{ 46 | "assets/js/mermaid.min.js", 47 | "assets/js/hljs/highlight.min.js", 48 | "assets/js/extra.js", 49 | }, 50 | "markdown_extensions": []interface{}{ 51 | "admonition", 52 | map[string]interface{}{"toc": map[string]interface{}{"permalink": true}}, 53 | "pymdownx.details", 54 | "pymdownx.inlinehilite", 55 | map[string]interface{}{"pymdownx.highlight": map[string]interface{}{"use_pygments": false}}, 56 | "pymdownx.smartsymbols", 57 | map[string]interface{}{"pymdownx.superfences": map[string]interface{}{ 58 | "custom_fences": []interface{}{map[string]interface{}{ 59 | "class": "mermaid", 60 | "format": "!!python/name:pymdownx.superfences.fence_div_format", 61 | "name": "mermaid", 62 | }}, 63 | }}, 64 | "pymdownx.tasklist", 65 | }, 66 | "nav": []interface{}{ 67 | map[string]interface{}{"Welcome": "index.md"}, 68 | map[string]interface{}{ 69 | "Getting Started": []interface{}{ 70 | map[string]interface{}{"Quick Start": "getting-started/quick-start.md"}, 71 | map[string]interface{}{"Build": "getting-started/build.md"}, 72 | map[string]interface{}{"Snapshots": "getting-started/snapshots.md"}, 73 | map[string]interface{}{"CLI": "getting-started/cli.md"}, 74 | map[string]interface{}{"Super-Peers": "getting-started/super-peers.md"}, 75 | }, 76 | }, 77 | map[string]interface{}{ 78 | "Configuration": []interface{}{ 79 | map[string]interface{}{"Overview": "configuration/index.md"}, 80 | }, 81 | }, 82 | map[string]interface{}{"Contributing": []interface{}{ 83 | map[string]interface{}{"Submitting Issues": "contributing/submitting_issues.md"}, 84 | map[string]interface{}{"Submiting PRs": "contributing/submitting_pull_request.md"}, 85 | }}, 86 | map[string]interface{}{ 87 | "Architecture": []interface{}{ 88 | map[string]interface{}{"Concepts": "architecture/concepts.md"}, 89 | map[string]interface{}{"Diagrams": "architecture/diagrams.md"}, 90 | }, 91 | }, 92 | }, 93 | "plugins": []interface{}{ 94 | "search", 95 | map[string]interface{}{ 96 | "git-revision-date-localized": map[string]interface{}{"fallback_to_build_date": true, "type": "date"}, 97 | }, 98 | }, 99 | "repo_name": "drasyl-overlay/drasyl", 100 | "repo_url": "https://github.com/drasyl-overlay/drasyl", 101 | "site_author": "drasyl", 102 | "site_description": "drasyl Documentation", 103 | "site_name": "drasyl", 104 | "site_url": "https://docs.drasyl.org", 105 | "theme": map[string]interface{}{ 106 | "favicon": "assets/img/favicon.ico", 107 | "feature": map[string]interface{}{"tabs": false}, 108 | "i18n": map[string]interface{}{ 109 | "next": "Next", 110 | "prev": "Previous", 111 | }, 112 | "icon": map[string]interface{}{"repo": "fontawesome/brands/github"}, 113 | "include_sidebar": true, 114 | "language": "en", 115 | "logo": "assets/img/drasyl.png", 116 | "name": "material", 117 | "palette": map[string]interface{}{ 118 | "accent": "teal", 119 | "primary": "teal", 120 | }, 121 | }, 122 | }, 123 | }, 124 | { 125 | desc: "empty", 126 | filename: "empty-mkdocs.yml", 127 | expected: map[string]interface{}{}, 128 | }, 129 | { 130 | desc: "traefik", 131 | filename: "traefik-mkdocs.yml", 132 | expected: map[string]interface{}{ 133 | "site_name": "Traefik", 134 | "docs_dir": "docs", 135 | "dev_addr": "0.0.0.0:8000", 136 | "extra_css": []interface{}{"theme/styles/extra.css", "theme/styles/atom-one-light.css"}, 137 | "site_description": "Traefik Documentation", "site_author": "containo.us", 138 | "markdown_extensions": []interface{}{ 139 | "admonition", 140 | map[string]interface{}{ 141 | "toc": map[string]interface{}{"permalink": true}, 142 | }, 143 | }, 144 | "site_url": "https://docs.traefik.io", 145 | "copyright": "Copyright © 2016-2019 Containous", 146 | "extra": map[string]interface{}{ 147 | "traefikVersion": TempPrefixEnvName + "TRAEFIK_VERSION", 148 | }, 149 | "theme": map[string]interface{}{ 150 | "include_sidebar": true, 151 | "favicon": "img/traefik.icon.png", 152 | "feature": map[string]interface{}{"tabs": false}, 153 | "i18n": map[string]interface{}{"prev": "Previous", "next": "Next"}, 154 | "name": "material", 155 | "custom_dir": "docs/theme", 156 | "language": "en", 157 | "logo": "img/traefik.logo.png", 158 | "palette": map[string]interface{}{"primary": "cyan", "accent": "cyan"}, 159 | }, 160 | "google_analytics": []interface{}{"UA-51880359-3", "docs.traefik.io"}, 161 | "extra_javascript": []interface{}{"theme/js/hljs/highlight.pack.js", "theme/js/extra.js"}, 162 | "pages": []interface{}{ 163 | map[string]interface{}{ 164 | "Getting Started": "index.md", 165 | }, 166 | map[string]interface{}{ 167 | "Basics": "basics.md", 168 | }, 169 | map[string]interface{}{ 170 | "Configuration": []interface{}{ 171 | map[string]interface{}{"Commons": "configuration/commons.md"}, 172 | map[string]interface{}{"Logs": "configuration/logs.md"}, 173 | map[string]interface{}{"EntryPoints": "configuration/entrypoints.md"}, 174 | map[string]interface{}{"Let's Encrypt": "configuration/acme.md"}, 175 | map[string]interface{}{"API / Dashboard": "configuration/api.md"}, 176 | map[string]interface{}{"BoltDB": "configuration/backends/boltdb.md"}, 177 | map[string]interface{}{"Consul": "configuration/backends/consul.md"}, 178 | map[string]interface{}{"Consul Catalog": "configuration/backends/consulcatalog.md"}, 179 | map[string]interface{}{"Docker": "configuration/backends/docker.md"}, 180 | map[string]interface{}{"DynamoDB": "configuration/backends/dynamodb.md"}, 181 | map[string]interface{}{"ECS": "configuration/backends/ecs.md"}, 182 | map[string]interface{}{"Etcd": "configuration/backends/etcd.md"}, 183 | map[string]interface{}{"Eureka": "configuration/backends/eureka.md"}, 184 | map[string]interface{}{"File": "configuration/backends/file.md"}, 185 | map[string]interface{}{"Kubernetes Ingress": "configuration/backends/kubernetes.md"}, 186 | map[string]interface{}{"Marathon": "configuration/backends/marathon.md"}, 187 | map[string]interface{}{"Mesos": "configuration/backends/mesos.md"}, 188 | map[string]interface{}{"Rancher": "configuration/backends/rancher.md"}, 189 | map[string]interface{}{"Rest": "configuration/backends/rest.md"}, 190 | map[string]interface{}{"Azure Service Fabric": "configuration/backends/servicefabric.md"}, 191 | map[string]interface{}{"Zookeeper": "configuration/backends/zookeeper.md"}, 192 | map[string]interface{}{"Ping": "configuration/ping.md"}, 193 | map[string]interface{}{"Metrics": "configuration/metrics.md"}, 194 | map[string]interface{}{"Tracing": "configuration/tracing.md"}, 195 | }, 196 | }, 197 | map[string]interface{}{ 198 | "User Guides": []interface{}{ 199 | map[string]interface{}{"Configuration Examples": "user-guide/examples.md"}, 200 | map[string]interface{}{"Swarm Mode Cluster": "user-guide/swarm-mode.md"}, 201 | map[string]interface{}{"Swarm Cluster": "user-guide/swarm.md"}, 202 | map[string]interface{}{"Let's Encrypt & Docker": "user-guide/docker-and-lets-encrypt.md"}, 203 | map[string]interface{}{"Kubernetes": "user-guide/kubernetes.md"}, 204 | map[string]interface{}{"Marathon": "user-guide/marathon.md"}, 205 | map[string]interface{}{"Key-value Store Configuration": "user-guide/kv-config.md"}, 206 | map[string]interface{}{"Clustering/HA": "user-guide/cluster.md"}, 207 | map[string]interface{}{"gRPC Example": "user-guide/grpc.md"}, 208 | map[string]interface{}{"Traefik cluster example with Swarm": "user-guide/cluster-docker-consul.md"}, 209 | }, 210 | }, 211 | }, 212 | "repo_name": "GitHub", 213 | "repo_url": "https://github.com/traefik/traefik", 214 | }, 215 | }, 216 | } 217 | 218 | for _, test := range testCases { 219 | test := test 220 | t.Run(test.desc, func(t *testing.T) { 221 | t.Parallel() 222 | 223 | content, err := Read(filepath.Join(".", "fixtures", test.filename)) 224 | require.NoError(t, err) 225 | 226 | assert.Equal(t, test.expected, content) 227 | }) 228 | } 229 | } 230 | 231 | func TestGetDocsDir(t *testing.T) { 232 | testCases := []struct { 233 | desc string 234 | manifestFilePath string 235 | content map[string]interface{} 236 | expected string 237 | }{ 238 | { 239 | desc: "no docs_dir attribute", 240 | manifestFilePath: filepath.Join("foo", "bar", FileName), 241 | content: map[string]interface{}{}, 242 | expected: filepath.Join("foo", "bar", "docs"), 243 | }, 244 | { 245 | desc: "with docs_dir attribute", 246 | manifestFilePath: filepath.Join("foo", "bar", FileName), 247 | content: map[string]interface{}{ 248 | "docs_dir": "/doc", 249 | }, 250 | expected: filepath.Join("foo", "bar", "doc"), 251 | }, 252 | } 253 | 254 | for _, test := range testCases { 255 | test := test 256 | t.Run(test.desc, func(t *testing.T) { 257 | t.Parallel() 258 | 259 | docDir := GetDocsDir(test.content, test.manifestFilePath) 260 | 261 | assert.Equal(t, test.expected, docDir) 262 | }) 263 | } 264 | } 265 | 266 | func TestAppendExtraJs(t *testing.T) { 267 | testCases := []struct { 268 | desc string 269 | jsFile string 270 | content map[string]interface{} 271 | expected map[string]interface{} 272 | }{ 273 | { 274 | desc: "empty", 275 | jsFile: "", 276 | content: map[string]interface{}{}, 277 | expected: map[string]interface{}{}, 278 | }, 279 | { 280 | desc: "append to non existing extra_javascript attribute", 281 | jsFile: "test.js", 282 | content: map[string]interface{}{}, 283 | expected: map[string]interface{}{ 284 | "extra_javascript": []interface{}{"test.js"}, 285 | }, 286 | }, 287 | { 288 | desc: "append to existing extra_javascript attribute", 289 | jsFile: "test.js", 290 | content: map[string]interface{}{ 291 | "extra_javascript": []interface{}{"foo.js", "bar.js"}, 292 | }, 293 | expected: map[string]interface{}{ 294 | "extra_javascript": []interface{}{"foo.js", "bar.js", "test.js"}, 295 | }, 296 | }, 297 | { 298 | desc: "append an already existing file to extra_javascript attribute", 299 | jsFile: "test.js", 300 | content: map[string]interface{}{ 301 | "extra_javascript": []interface{}{"test.js"}, 302 | }, 303 | expected: map[string]interface{}{ 304 | "extra_javascript": []interface{}{"test.js", "test.js"}, 305 | }, 306 | }, 307 | { 308 | desc: "append empty file name", 309 | jsFile: "", 310 | content: map[string]interface{}{ 311 | "extra_javascript": []interface{}{"foo.js", "bar.js"}, 312 | }, 313 | expected: map[string]interface{}{ 314 | "extra_javascript": []interface{}{"foo.js", "bar.js"}, 315 | }, 316 | }, 317 | } 318 | 319 | for _, test := range testCases { 320 | test := test 321 | t.Run(test.desc, func(t *testing.T) { 322 | t.Parallel() 323 | 324 | AppendExtraJs(test.content, test.jsFile) 325 | 326 | assert.Equal(t, test.expected, test.content) 327 | }) 328 | } 329 | } 330 | 331 | func TestAppendExtraCSS(t *testing.T) { 332 | testCases := []struct { 333 | desc string 334 | cssFile string 335 | content map[string]interface{} 336 | expected map[string]interface{} 337 | }{ 338 | { 339 | desc: "empty", 340 | cssFile: "", 341 | content: map[string]interface{}{}, 342 | expected: map[string]interface{}{}, 343 | }, 344 | { 345 | desc: "append to non existing extra_css attribute", 346 | cssFile: "test.css", 347 | content: map[string]interface{}{}, 348 | expected: map[string]interface{}{ 349 | "extra_css": []interface{}{"test.css"}, 350 | }, 351 | }, 352 | { 353 | desc: "append to existing extra_css attribute", 354 | cssFile: "test.css", 355 | content: map[string]interface{}{ 356 | "extra_css": []interface{}{"foo.css", "bar.css"}, 357 | }, 358 | expected: map[string]interface{}{ 359 | "extra_css": []interface{}{"foo.css", "bar.css", "test.css"}, 360 | }, 361 | }, 362 | { 363 | desc: "append an already existing file to extra_css attribute", 364 | cssFile: "test.css", 365 | content: map[string]interface{}{ 366 | "extra_css": []interface{}{"test.css"}, 367 | }, 368 | expected: map[string]interface{}{ 369 | "extra_css": []interface{}{"test.css", "test.css"}, 370 | }, 371 | }, 372 | { 373 | desc: "append empty file name", 374 | cssFile: "", 375 | content: map[string]interface{}{ 376 | "extra_css": []interface{}{"foo.css", "bar.css"}, 377 | }, 378 | expected: map[string]interface{}{ 379 | "extra_css": []interface{}{"foo.css", "bar.css"}, 380 | }, 381 | }, 382 | } 383 | 384 | for _, test := range testCases { 385 | test := test 386 | t.Run(test.desc, func(t *testing.T) { 387 | t.Parallel() 388 | 389 | AppendExtraCSS(test.content, test.cssFile) 390 | 391 | assert.Equal(t, test.expected, test.content) 392 | }) 393 | } 394 | } 395 | 396 | func TestAddEditionURI(t *testing.T) { 397 | testCases := []struct { 398 | desc string 399 | manif map[string]interface{} 400 | version string 401 | baseDir string 402 | override bool 403 | expected map[string]interface{} 404 | }{ 405 | { 406 | desc: "no version", 407 | manif: map[string]interface{}{}, 408 | version: "", 409 | expected: map[string]interface{}{ 410 | "edit_uri": "edit/master/docs/", 411 | }, 412 | }, 413 | { 414 | desc: "no version, no override", 415 | manif: map[string]interface{}{ 416 | "edit_uri": "edit/v666/docs/", 417 | }, 418 | version: "", 419 | expected: map[string]interface{}{ 420 | "edit_uri": "edit/v666/docs/", 421 | }, 422 | }, 423 | { 424 | desc: "no version, override", 425 | manif: map[string]interface{}{ 426 | "edit_uri": "edit/v1/docs/", 427 | }, 428 | version: "", 429 | override: true, 430 | expected: map[string]interface{}{ 431 | "edit_uri": "edit/master/docs/", 432 | }, 433 | }, 434 | { 435 | desc: "version, no override", 436 | manif: map[string]interface{}{ 437 | "edit_uri": "edit/v1/docs/", 438 | }, 439 | version: "v2", 440 | expected: map[string]interface{}{ 441 | "edit_uri": "edit/v1/docs/", 442 | }, 443 | }, 444 | { 445 | desc: "version, override", 446 | manif: map[string]interface{}{ 447 | "edit_uri": "edit/v1/docs/", 448 | }, 449 | version: "v2", 450 | override: true, 451 | expected: map[string]interface{}{ 452 | "edit_uri": "edit/v2/docs/", 453 | }, 454 | }, 455 | { 456 | desc: "version, no override, base dir", 457 | manif: map[string]interface{}{ 458 | "edit_uri": "edit/v1/docs/", 459 | }, 460 | version: "v2", 461 | baseDir: "foo", 462 | expected: map[string]interface{}{ 463 | "edit_uri": "edit/v1/docs/", 464 | }, 465 | }, 466 | { 467 | desc: "version, override, base dir", 468 | manif: map[string]interface{}{ 469 | "edit_uri": "edit/v1/docs/", 470 | }, 471 | version: "v2", 472 | baseDir: "foo", 473 | override: true, 474 | expected: map[string]interface{}{ 475 | "edit_uri": "edit/v2/foo/docs/", 476 | }, 477 | }, 478 | } 479 | 480 | for _, test := range testCases { 481 | test := test 482 | t.Run(test.desc, func(t *testing.T) { 483 | t.Parallel() 484 | 485 | AddEditionURI(test.manif, test.version, test.baseDir, test.override) 486 | 487 | assert.Equal(t, test.expected, test.manif) 488 | }) 489 | } 490 | } 491 | -------------------------------------------------------------------------------- /menu/css.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | const menuCSSFileName = "structor-menu.css" 10 | 11 | func writeCSSFile(manifestDocsDir string, menuContent Content) (string, error) { 12 | if len(menuContent.CSS) == 0 { 13 | return "", nil 14 | } 15 | 16 | cssDir := filepath.Join(manifestDocsDir, "theme", "css") 17 | if _, errStat := os.Stat(cssDir); os.IsNotExist(errStat) { 18 | errDir := os.MkdirAll(cssDir, os.ModePerm) 19 | if errDir != nil { 20 | return "", fmt.Errorf("error when create CSS folder: %w", errDir) 21 | } 22 | } 23 | 24 | err := os.WriteFile(filepath.Join(cssDir, menuCSSFileName), menuContent.CSS, os.ModePerm) 25 | if err != nil { 26 | return "", fmt.Errorf("error when trying ro write CSS file: %w", err) 27 | } 28 | 29 | return filepath.Join("theme", "css", menuCSSFileName), nil 30 | } 31 | -------------------------------------------------------------------------------- /menu/fixtures/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Structor 2 | site_description: Structor Documentation 3 | site_author: containo.us 4 | dev_addr: 0.0.0.0:8000 5 | 6 | repo_name: 'GitHub' 7 | repo_url: 'https://github.com/traefik/structor' 8 | 9 | docs_dir: 'docs' 10 | 11 | theme: 12 | name: 'material' 13 | custom_dir: 'docs/theme' 14 | language: en 15 | include_sidebar: true 16 | favicon: img/traefik.icon.png 17 | logo: img/traefik.logo.png 18 | palette: 19 | primary: 'blue' 20 | accent: 'light blue' 21 | feature: 22 | tabs: false 23 | i18n: 24 | prev: 'Previous' 25 | next: 'Next' 26 | 27 | copyright: "Copyright © 2016-2018 Containous SAS" 28 | 29 | extra_css: 30 | - theme/styles/extra.css 31 | - theme/styles/atom-one-light.css 32 | 33 | extra_javascript: 34 | - theme/js/hljs/highlight.pack.js 35 | - theme/js/extra.js 36 | 37 | pages: 38 | - Getting Started: index.md 39 | - Basics: basics.md 40 | - Configuration: 41 | - 'Commons': 'configuration/commons.md' 42 | - 'Logs': 'configuration/logs.md' 43 | -------------------------------------------------------------------------------- /menu/fixtures/mkdocs_without-extra.yml: -------------------------------------------------------------------------------- 1 | site_name: Structor 2 | site_description: Structor Documentation 3 | site_author: containo.us 4 | dev_addr: 0.0.0.0:8000 5 | 6 | repo_name: 'GitHub' 7 | repo_url: 'https://github.com/traefik/structor' 8 | 9 | docs_dir: 'docs' 10 | 11 | theme: 12 | name: 'material' 13 | custom_dir: 'docs/theme' 14 | language: en 15 | include_sidebar: true 16 | favicon: img/traefik.icon.png 17 | logo: img/traefik.logo.png 18 | palette: 19 | primary: 'blue' 20 | accent: 'light blue' 21 | feature: 22 | tabs: false 23 | i18n: 24 | prev: 'Previous' 25 | next: 'Next' 26 | 27 | copyright: "Copyright © 2016-2018 Containous SAS" 28 | 29 | pages: 30 | - Getting Started: index.md 31 | - Basics: basics.md 32 | - Configuration: 33 | - 'Commons': 'configuration/commons.md' 34 | - 'Logs': 'configuration/logs.md' 35 | -------------------------------------------------------------------------------- /menu/fixtures/server/test-menu.css.gotmpl: -------------------------------------------------------------------------------- 1 | #server { 2 | background-color: lightblue; 3 | } -------------------------------------------------------------------------------- /menu/fixtures/server/test-menu.js.gotmpl: -------------------------------------------------------------------------------- 1 | console.log("serve") -------------------------------------------------------------------------------- /menu/fixtures/test-menu.css.gotmpl: -------------------------------------------------------------------------------- 1 | #file { 2 | background-color: lightblue; 3 | } -------------------------------------------------------------------------------- /menu/fixtures/test-menu.js.gotmpl: -------------------------------------------------------------------------------- 1 | console.log("file") -------------------------------------------------------------------------------- /menu/fixtures/test_custom-css-1.yml: -------------------------------------------------------------------------------- 1 | copyright: Copyright © 2016-2018 Containous SAS 2 | dev_addr: 0.0.0.0:8000 3 | docs_dir: docs 4 | extra_css: 5 | - theme/styles/extra.css 6 | - theme/styles/atom-one-light.css 7 | - structor-custom.css 8 | extra_javascript: 9 | - theme/js/hljs/highlight.pack.js 10 | - theme/js/extra.js 11 | pages: 12 | - Getting Started: index.md 13 | - Basics: basics.md 14 | - Configuration: 15 | - Commons: configuration/commons.md 16 | - Logs: configuration/logs.md 17 | repo_name: GitHub 18 | repo_url: https://github.com/traefik/structor 19 | site_author: containo.us 20 | site_description: Structor Documentation 21 | site_name: Structor 22 | site_url: "" 23 | theme: 24 | custom_dir: docs/theme 25 | favicon: img/traefik.icon.png 26 | feature: 27 | tabs: false 28 | i18n: 29 | next: Next 30 | prev: Previous 31 | include_sidebar: true 32 | language: en 33 | logo: img/traefik.logo.png 34 | name: material 35 | palette: 36 | accent: light blue 37 | primary: blue 38 | -------------------------------------------------------------------------------- /menu/fixtures/test_custom-css-2.yml: -------------------------------------------------------------------------------- 1 | copyright: Copyright © 2016-2018 Containous SAS 2 | dev_addr: 0.0.0.0:8000 3 | docs_dir: docs 4 | extra_css: 5 | - structor-custom.css 6 | pages: 7 | - Getting Started: index.md 8 | - Basics: basics.md 9 | - Configuration: 10 | - Commons: configuration/commons.md 11 | - Logs: configuration/logs.md 12 | repo_name: GitHub 13 | repo_url: https://github.com/traefik/structor 14 | site_author: containo.us 15 | site_description: Structor Documentation 16 | site_name: Structor 17 | site_url: "" 18 | theme: 19 | custom_dir: docs/theme 20 | favicon: img/traefik.icon.png 21 | feature: 22 | tabs: false 23 | i18n: 24 | next: Next 25 | prev: Previous 26 | include_sidebar: true 27 | language: en 28 | logo: img/traefik.logo.png 29 | name: material 30 | palette: 31 | accent: light blue 32 | primary: blue 33 | -------------------------------------------------------------------------------- /menu/fixtures/test_custom-js-1.yml: -------------------------------------------------------------------------------- 1 | copyright: Copyright © 2016-2018 Containous SAS 2 | dev_addr: 0.0.0.0:8000 3 | docs_dir: docs 4 | extra_css: 5 | - theme/styles/extra.css 6 | - theme/styles/atom-one-light.css 7 | extra_javascript: 8 | - theme/js/hljs/highlight.pack.js 9 | - theme/js/extra.js 10 | - structor-custom.js 11 | pages: 12 | - Getting Started: index.md 13 | - Basics: basics.md 14 | - Configuration: 15 | - Commons: configuration/commons.md 16 | - Logs: configuration/logs.md 17 | repo_name: GitHub 18 | repo_url: https://github.com/traefik/structor 19 | site_author: containo.us 20 | site_description: Structor Documentation 21 | site_name: Structor 22 | site_url: "" 23 | theme: 24 | custom_dir: docs/theme 25 | favicon: img/traefik.icon.png 26 | feature: 27 | tabs: false 28 | i18n: 29 | next: Next 30 | prev: Previous 31 | include_sidebar: true 32 | language: en 33 | logo: img/traefik.logo.png 34 | name: material 35 | palette: 36 | accent: light blue 37 | primary: blue 38 | -------------------------------------------------------------------------------- /menu/fixtures/test_custom-js-2.yml: -------------------------------------------------------------------------------- 1 | copyright: Copyright © 2016-2018 Containous SAS 2 | dev_addr: 0.0.0.0:8000 3 | docs_dir: docs 4 | extra_javascript: 5 | - structor-custom.js 6 | pages: 7 | - Getting Started: index.md 8 | - Basics: basics.md 9 | - Configuration: 10 | - Commons: configuration/commons.md 11 | - Logs: configuration/logs.md 12 | repo_name: GitHub 13 | repo_url: https://github.com/traefik/structor 14 | site_author: containo.us 15 | site_description: Structor Documentation 16 | site_name: Structor 17 | site_url: "" 18 | theme: 19 | custom_dir: docs/theme 20 | favicon: img/traefik.icon.png 21 | feature: 22 | tabs: false 23 | i18n: 24 | next: Next 25 | prev: Previous 26 | include_sidebar: true 27 | language: en 28 | logo: img/traefik.logo.png 29 | name: material 30 | palette: 31 | accent: light blue 32 | primary: blue 33 | -------------------------------------------------------------------------------- /menu/fixtures/test_no-custom-files.yml: -------------------------------------------------------------------------------- 1 | copyright: Copyright © 2016-2018 Containous SAS 2 | dev_addr: 0.0.0.0:8000 3 | docs_dir: docs 4 | extra_css: 5 | - theme/styles/extra.css 6 | - theme/styles/atom-one-light.css 7 | extra_javascript: 8 | - theme/js/hljs/highlight.pack.js 9 | - theme/js/extra.js 10 | pages: 11 | - Getting Started: index.md 12 | - Basics: basics.md 13 | - Configuration: 14 | - Commons: configuration/commons.md 15 | - Logs: configuration/logs.md 16 | repo_name: GitHub 17 | repo_url: https://github.com/traefik/structor 18 | site_author: containo.us 19 | site_description: Structor Documentation 20 | site_name: Structor 21 | site_url: "" 22 | theme: 23 | custom_dir: docs/theme 24 | favicon: img/traefik.icon.png 25 | feature: 26 | tabs: false 27 | i18n: 28 | next: Next 29 | prev: Previous 30 | include_sidebar: true 31 | language: en 32 | logo: img/traefik.logo.png 33 | name: material 34 | palette: 35 | accent: light blue 36 | primary: blue 37 | -------------------------------------------------------------------------------- /menu/fixtures/traefik-menu-obsolete.js: -------------------------------------------------------------------------------- 1 | var versions = [ 2 | {path: "master", text: "Experimental", selected: false }, 3 | {path: "v1.10", text: "v1.10 (RC)", selected: false }, 4 | {path: "", text: "v1.9 Latest", selected: false }, 5 | {path: "v1.8", text: "v1.8", selected: true }, 6 | ]; 7 | 8 | 9 | 10 | 11 | function createBanner(parentElem, versions) { 12 | if (!parentElem || window.location.host !== "doc.traefik.io") { 13 | return; 14 | } 15 | 16 | const products = { 17 | traefik: { 18 | color: '#2aa2c1', 19 | backgroundColor: '#2aa2c11a', 20 | fullName: 'Traefik Proxy', 21 | }, 22 | 'traefik-enterprise': { 23 | color: '#337fe6', 24 | backgroundColor: '#337fe61a', 25 | fullName: 'Traefik Enterprise', 26 | }, 27 | 'traefik-mesh': { 28 | color: '#be46dd', 29 | backgroundColor: '#be46dd1a', 30 | fullName: 'Traefik Mesh', 31 | }, 32 | } 33 | 34 | const [,productName] = window.location.pathname.split('/'); 35 | const currentProduct = products[productName]; 36 | const currentVersion = versions.find(v => v.selected); 37 | const preExistentBanner = document.getElementById('outdated-doc-banner'); 38 | 39 | if (!currentProduct || !currentVersion || !!preExistentBanner) return; 40 | 41 | const cssCode = ` 42 | #obsolete-banner { 43 | display: flex; 44 | width: 100%; 45 | align-items: center; 46 | justify-content: center; 47 | max-width: 1274px; 48 | margin: 0 auto; 49 | } 50 | #obsolete-banner .obsolete-banner-content { 51 | display: flex; 52 | align-items: center; 53 | height: 40px; 54 | margin: 24px; 55 | padding: 11px 16px; 56 | border-radius: 8px; 57 | background-color: ${currentProduct.backgroundColor}; 58 | gap: 16px; 59 | font-family: Rubik, sans-serif; 60 | font-size: 14px; 61 | color: ${currentProduct.color}; 62 | box-sizing: border-box; 63 | width: 100%; 64 | } 65 | #obsolete-banner .obsolete-banner-content strong { font-weight: bold; } 66 | #obsolete-banner .obsolete-banner-content a { color: ${currentProduct.color}; text-decoration: none; } 67 | #obsolete-banner .obsolete-banner-content a:hover { text-decoration: underline; } 68 | #obsolete-banner .obsolete-banner-content p { margin: 0; } 69 | ` 70 | 71 | const banner = document.createElement('div'); 72 | banner.id = 'obsolete-banner'; 73 | banner.innerHTML = ` 74 |
75 | OLD VERSION 76 |

77 | You're looking at documentation for ${currentProduct.fullName} ${currentVersion.text}. 78 | Click here to see the latest version. → 79 |

80 |
81 | `; 82 | 83 | // Append HTML 84 | parentElem.prepend(banner); 85 | 86 | // Append Styling 87 | const [head] = document.getElementsByTagName("head"); 88 | if (!document.getElementById("obsolete-banner-style")) { 89 | const styleElem = document.createElement("style"); 90 | styleElem.id = "obsolete-banner-style"; 91 | 92 | if (styleElem.styleSheet) { 93 | styleElem.styleSheet.cssText = cssCode; 94 | } else { 95 | styleElem.appendChild(document.createTextNode(cssCode)); 96 | } 97 | 98 | head.appendChild(styleElem); 99 | } 100 | } 101 | 102 | function addBannerMaterial(versions) { 103 | const elem = document.querySelector('body > div.md-container'); 104 | createBanner(elem, versions) 105 | } 106 | 107 | function addBannerUnited() { 108 | const elem = document.querySelector('body > div.container'); 109 | createBanner(elem, versions) 110 | } 111 | 112 | // Material theme 113 | 114 | function addMaterialMenu(elt, versions) { 115 | const current = versions.find(function (value) { 116 | return value.selected; 117 | }) 118 | 119 | const rootLi = document.createElement('li'); 120 | rootLi.classList.add('md-nav__item'); 121 | rootLi.classList.add('md-nav__item--nested'); 122 | 123 | const input = document.createElement('input'); 124 | input.classList.add('md-toggle'); 125 | input.classList.add('md-nav__toggle'); 126 | input.setAttribute('data-md-toggle', 'nav-10000000'); 127 | input.id = "nav-10000000"; 128 | input.type = 'checkbox'; 129 | 130 | rootLi.appendChild(input); 131 | 132 | const lbl01 = document.createElement('label'); 133 | lbl01.classList.add('md-nav__link'); 134 | lbl01.setAttribute('for', 'nav-10000000'); 135 | 136 | const spanTitle01 = document.createElement('span'); 137 | spanTitle01.classList.add('md-nav__item-title'); 138 | spanTitle01.textContent = current.text+ " "; 139 | 140 | lbl01.appendChild(spanTitle01); 141 | 142 | const spanIcon01 = document.createElement('span'); 143 | spanIcon01.classList.add('md-nav__icon'); 144 | spanIcon01.classList.add('md-icon'); 145 | 146 | lbl01.appendChild(spanIcon01); 147 | 148 | rootLi.appendChild(lbl01); 149 | 150 | const nav = document.createElement('nav') 151 | nav.classList.add('md-nav'); 152 | nav.setAttribute('data-md-component','collapsible'); 153 | nav.setAttribute('aria-label', current.text); 154 | nav.setAttribute('data-md-level','1'); 155 | 156 | rootLi.appendChild(nav); 157 | 158 | const lbl02 = document.createElement('label'); 159 | lbl02.classList.add('md-nav__title'); 160 | lbl02.setAttribute('for', 'nav-10000000'); 161 | lbl02.textContent = current.text + " "; 162 | 163 | const spanIcon02 = document.createElement('span'); 164 | spanIcon02.classList.add('md-nav__icon'); 165 | spanIcon02.classList.add('md-icon'); 166 | 167 | lbl02.appendChild(spanIcon02); 168 | 169 | nav.appendChild(lbl02); 170 | 171 | const ul = document.createElement('ul'); 172 | ul.classList.add('md-nav__list'); 173 | ul.setAttribute('data-md-scrollfix',''); 174 | 175 | nav.appendChild(ul); 176 | 177 | for (let i = 0; i < versions.length; i++) { 178 | const li = document.createElement('li'); 179 | li.classList.add('md-nav__item'); 180 | 181 | ul.appendChild(li); 182 | 183 | const a = document.createElement('a'); 184 | a.classList.add('md-nav__link'); 185 | if (versions[i].selected) { 186 | a.classList.add('md-nav__link--active'); 187 | } 188 | a.href = window.location.protocol + "//" + window.location.host + "/"; 189 | if (window.location.host === "doc.traefik.io") { 190 | a.href = a.href + window.location.pathname.split('/')[1] + "/"; 191 | } 192 | if (versions[i].path) { 193 | a.href = a.href + versions[i].path + "/"; 194 | } 195 | a.title = versions[i].text; 196 | a.text = versions[i].text; 197 | 198 | li.appendChild(a); 199 | } 200 | 201 | elt.appendChild(rootLi); 202 | } 203 | 204 | // United theme 205 | 206 | function addMenu(elt, versions){ 207 | const li = document.createElement('li'); 208 | li.classList.add('md-nav__item'); 209 | li.style.cssText = 'padding-top: 1em;'; 210 | 211 | const select = document.createElement('select'); 212 | select.classList.add('md-nav__link'); 213 | select.style.cssText = 'background: white;border: none;color: #00BCD4;-webkit-border-radius: 5px;-moz-border-radius: 5px;border-radius: 5px;overflow: hidden;padding: 0.1em;' 214 | select.setAttribute('onchange', 'location = this.options[this.selectedIndex].value;'); 215 | 216 | for (let i = 0; i < versions.length; i++) { 217 | let opt = document.createElement('option'); 218 | opt.value = window.location.protocol + "//" + window.location.host + "/"; 219 | if (window.location.host === "doc.traefik.io") { 220 | opt.value = opt.value + window.location.pathname.split('/')[1] + "/" 221 | } 222 | if (versions[i].path) { 223 | opt.value = opt.value + versions[i].path + "/" 224 | } 225 | opt.text = versions[i].text; 226 | opt.selected = versions[i].selected; 227 | select.appendChild(opt); 228 | } 229 | 230 | li.appendChild(select); 231 | elt.appendChild(li); 232 | } 233 | 234 | 235 | const unitedSelector = 'div.navbar.navbar-default.navbar-fixed-top div.container div.navbar-collapse.collapse ul.nav.navbar-nav.navbar-right'; 236 | const materialSelector = 'div.md-container main.md-main div.md-main__inner.md-grid div.md-sidebar.md-sidebar--primary div.md-sidebar__scrollwrap div.md-sidebar__inner nav.md-nav.md-nav--primary ul.md-nav__list'; 237 | 238 | let elt = document.querySelector(materialSelector); 239 | if (elt) { 240 | addMaterialMenu(elt, versions); 241 | addBannerMaterial(versions); 242 | } else { 243 | const elt = document.querySelector(unitedSelector); 244 | addMenu(elt, versions); 245 | addBannerUnited(versions); 246 | } 247 | -------------------------------------------------------------------------------- /menu/fixtures/traefik-menu.js: -------------------------------------------------------------------------------- 1 | var versions = [ 2 | {path: "master", text: "Experimental", selected: false }, 3 | {path: "v1.10", text: "v1.10 (RC)", selected: true }, 4 | {path: "", text: "v1.9 Latest", selected: false }, 5 | {path: "v1.8", text: "v1.8", selected: false }, 6 | ]; 7 | 8 | 9 | 10 | 11 | 12 | // Material theme 13 | 14 | function addMaterialMenu(elt, versions) { 15 | const current = versions.find(function (value) { 16 | return value.selected; 17 | }) 18 | 19 | const rootLi = document.createElement('li'); 20 | rootLi.classList.add('md-nav__item'); 21 | rootLi.classList.add('md-nav__item--nested'); 22 | 23 | const input = document.createElement('input'); 24 | input.classList.add('md-toggle'); 25 | input.classList.add('md-nav__toggle'); 26 | input.setAttribute('data-md-toggle', 'nav-10000000'); 27 | input.id = "nav-10000000"; 28 | input.type = 'checkbox'; 29 | 30 | rootLi.appendChild(input); 31 | 32 | const lbl01 = document.createElement('label'); 33 | lbl01.classList.add('md-nav__link'); 34 | lbl01.setAttribute('for', 'nav-10000000'); 35 | 36 | const spanTitle01 = document.createElement('span'); 37 | spanTitle01.classList.add('md-nav__item-title'); 38 | spanTitle01.textContent = current.text+ " "; 39 | 40 | lbl01.appendChild(spanTitle01); 41 | 42 | const spanIcon01 = document.createElement('span'); 43 | spanIcon01.classList.add('md-nav__icon'); 44 | spanIcon01.classList.add('md-icon'); 45 | 46 | lbl01.appendChild(spanIcon01); 47 | 48 | rootLi.appendChild(lbl01); 49 | 50 | const nav = document.createElement('nav') 51 | nav.classList.add('md-nav'); 52 | nav.setAttribute('data-md-component','collapsible'); 53 | nav.setAttribute('aria-label', current.text); 54 | nav.setAttribute('data-md-level','1'); 55 | 56 | rootLi.appendChild(nav); 57 | 58 | const lbl02 = document.createElement('label'); 59 | lbl02.classList.add('md-nav__title'); 60 | lbl02.setAttribute('for', 'nav-10000000'); 61 | lbl02.textContent = current.text + " "; 62 | 63 | const spanIcon02 = document.createElement('span'); 64 | spanIcon02.classList.add('md-nav__icon'); 65 | spanIcon02.classList.add('md-icon'); 66 | 67 | lbl02.appendChild(spanIcon02); 68 | 69 | nav.appendChild(lbl02); 70 | 71 | const ul = document.createElement('ul'); 72 | ul.classList.add('md-nav__list'); 73 | ul.setAttribute('data-md-scrollfix',''); 74 | 75 | nav.appendChild(ul); 76 | 77 | for (let i = 0; i < versions.length; i++) { 78 | const li = document.createElement('li'); 79 | li.classList.add('md-nav__item'); 80 | 81 | ul.appendChild(li); 82 | 83 | const a = document.createElement('a'); 84 | a.classList.add('md-nav__link'); 85 | if (versions[i].selected) { 86 | a.classList.add('md-nav__link--active'); 87 | } 88 | a.href = window.location.protocol + "//" + window.location.host + "/"; 89 | if (window.location.host === "doc.traefik.io") { 90 | a.href = a.href + window.location.pathname.split('/')[1] + "/"; 91 | } 92 | if (versions[i].path) { 93 | a.href = a.href + versions[i].path + "/"; 94 | } 95 | a.title = versions[i].text; 96 | a.text = versions[i].text; 97 | 98 | li.appendChild(a); 99 | } 100 | 101 | elt.appendChild(rootLi); 102 | } 103 | 104 | // United theme 105 | 106 | function addMenu(elt, versions){ 107 | const li = document.createElement('li'); 108 | li.classList.add('md-nav__item'); 109 | li.style.cssText = 'padding-top: 1em;'; 110 | 111 | const select = document.createElement('select'); 112 | select.classList.add('md-nav__link'); 113 | select.style.cssText = 'background: white;border: none;color: #00BCD4;-webkit-border-radius: 5px;-moz-border-radius: 5px;border-radius: 5px;overflow: hidden;padding: 0.1em;' 114 | select.setAttribute('onchange', 'location = this.options[this.selectedIndex].value;'); 115 | 116 | for (let i = 0; i < versions.length; i++) { 117 | let opt = document.createElement('option'); 118 | opt.value = window.location.protocol + "//" + window.location.host + "/"; 119 | if (window.location.host === "doc.traefik.io") { 120 | opt.value = opt.value + window.location.pathname.split('/')[1] + "/" 121 | } 122 | if (versions[i].path) { 123 | opt.value = opt.value + versions[i].path + "/" 124 | } 125 | opt.text = versions[i].text; 126 | opt.selected = versions[i].selected; 127 | select.appendChild(opt); 128 | } 129 | 130 | li.appendChild(select); 131 | elt.appendChild(li); 132 | } 133 | 134 | 135 | const unitedSelector = 'div.navbar.navbar-default.navbar-fixed-top div.container div.navbar-collapse.collapse ul.nav.navbar-nav.navbar-right'; 136 | const materialSelector = 'div.md-container main.md-main div.md-main__inner.md-grid div.md-sidebar.md-sidebar--primary div.md-sidebar__scrollwrap div.md-sidebar__inner nav.md-nav.md-nav--primary ul.md-nav__list'; 137 | 138 | let elt = document.querySelector(materialSelector); 139 | if (elt) { 140 | addMaterialMenu(elt, versions); 141 | } else { 142 | const elt = document.querySelector(unitedSelector); 143 | addMenu(elt, versions); 144 | } 145 | -------------------------------------------------------------------------------- /menu/js.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/Masterminds/sprig/v3" 12 | "github.com/hashicorp/go-version" 13 | "github.com/traefik/structor/types" 14 | ) 15 | 16 | const menuJsFileName = "structor-menu.js" 17 | 18 | const ( 19 | stateLatest = "LATEST" 20 | stateExperimental = "EXPERIMENTAL" 21 | statePreFinalRelease = "PRE_FINAL_RELEASE" 22 | stateObsolete = "OBSOLETE" 23 | ) 24 | 25 | type optionVersion struct { 26 | Path string 27 | Text string 28 | Name string 29 | State string 30 | Selected bool 31 | } 32 | 33 | func writeJsFile(manifestDocsDir string, menuContent Content, versionsInfo types.VersionsInformation, branches []string) (string, error) { 34 | if len(menuContent.Js) == 0 { 35 | return "", nil 36 | } 37 | 38 | jsDir := filepath.Join(manifestDocsDir, "theme", "js") 39 | if _, errStat := os.Stat(jsDir); os.IsNotExist(errStat) { 40 | errDir := os.MkdirAll(jsDir, os.ModePerm) 41 | if errDir != nil { 42 | return "", fmt.Errorf("error when create JS folder: %w", errDir) 43 | } 44 | } 45 | 46 | menuFilePath := filepath.Join(jsDir, menuJsFileName) 47 | errBuild := buildJSFile(menuFilePath, versionsInfo, branches, string(menuContent.Js)) 48 | if errBuild != nil { 49 | return "", errBuild 50 | } 51 | 52 | return filepath.Join("theme", "js", menuJsFileName), nil 53 | } 54 | 55 | func buildJSFile(filePath string, versionsInfo types.VersionsInformation, branches []string, menuTemplate string) error { 56 | defaultFuncMap := sprig.TxtFuncMap() 57 | defaultFuncMap["IsObsolete"] = func(versions []optionVersion, current string) bool { 58 | for _, v := range versions { 59 | if v.Name == current && v.State == stateObsolete { 60 | return true 61 | } 62 | } 63 | return false 64 | } 65 | 66 | temp := template.New("menu-js").Funcs(defaultFuncMap) 67 | 68 | _, err := temp.Parse(menuTemplate) 69 | if err != nil { 70 | return fmt.Errorf("error during parsing template: %w", err) 71 | } 72 | 73 | versions, err := buildVersions(versionsInfo.Current, branches, versionsInfo.Latest, versionsInfo.Experimental) 74 | if err != nil { 75 | return fmt.Errorf("error when build versions: %w", err) 76 | } 77 | 78 | model := struct { 79 | Latest string 80 | Current string 81 | Versions []optionVersion 82 | }{ 83 | Latest: versionsInfo.Latest, 84 | Current: versionsInfo.Current, 85 | Versions: versions, 86 | } 87 | 88 | f, err := os.Create(filePath) 89 | if err != nil { 90 | return fmt.Errorf("error when create menu file: %w", err) 91 | } 92 | 93 | return temp.Execute(f, model) 94 | } 95 | 96 | func buildVersions(currentVersion string, branches []string, latestTagName, experimentalBranchName string) ([]optionVersion, error) { 97 | latestVersion, err := version.NewVersion(latestTagName) 98 | if err != nil { 99 | return nil, fmt.Errorf("failed to parse latest tag version %s: %w", latestTagName, err) 100 | } 101 | 102 | rawVersions, heads := parseBranches(branches) 103 | 104 | var versions []optionVersion 105 | for _, versionName := range rawVersions { 106 | selected := currentVersion == versionName 107 | 108 | switch versionName { 109 | case latestTagName: 110 | // skip, because we must use the branch instead of the tag 111 | case experimentalBranchName: 112 | versions = append(versions, optionVersion{ 113 | Path: experimentalBranchName, 114 | Text: "Experimental", 115 | Name: experimentalBranchName, 116 | State: stateExperimental, 117 | Selected: selected, 118 | }) 119 | 120 | default: 121 | simpleVersion, err := version.NewVersion(versionName) 122 | if err != nil { 123 | return nil, fmt.Errorf("failed to parse version %s: %w", versionName, err) 124 | } 125 | 126 | v := optionVersion{ 127 | Name: versionName, 128 | Selected: selected, 129 | } 130 | 131 | switch { 132 | case simpleVersion.GreaterThan(latestVersion): 133 | v.Path = versionName 134 | v.Text = versionName + " RC" 135 | v.State = statePreFinalRelease 136 | case sameMinor(simpleVersion, latestVersion): 137 | // latest version 138 | v.Text = versionName + " Latest" 139 | v.State = stateLatest 140 | default: 141 | v.Path = versionName 142 | v.Text = versionName 143 | if !isHeads(heads, simpleVersion) { 144 | v.State = stateObsolete 145 | } 146 | } 147 | 148 | versions = append(versions, v) 149 | } 150 | } 151 | 152 | return versions, nil 153 | } 154 | 155 | func parseBranches(branches []string) ([]string, map[int]*version.Version) { 156 | heads := map[int]*version.Version{} 157 | 158 | var rawVersions []string 159 | for _, branch := range branches { 160 | versionName := strings.Replace(branch, baseRemote, "", 1) 161 | rawVersions = append(rawVersions, versionName) 162 | 163 | v, err := version.NewVersion(versionName) 164 | if err != nil { 165 | continue 166 | } 167 | 168 | if p, ok := heads[v.Segments()[0]]; ok { 169 | if v.GreaterThan(p) { 170 | heads[v.Segments()[0]] = v 171 | } 172 | } else { 173 | heads[v.Segments()[0]] = v 174 | } 175 | } 176 | 177 | sort.Slice(rawVersions, func(i, j int) bool { 178 | vi, err := version.NewVersion(rawVersions[i]) 179 | if err != nil { 180 | return true 181 | } 182 | vj, err := version.NewVersion(rawVersions[j]) 183 | if err != nil { 184 | return false 185 | } 186 | 187 | return vj.LessThanOrEqual(vi) 188 | }) 189 | 190 | return rawVersions, heads 191 | } 192 | 193 | func sameMinor(v1, v2 *version.Version) bool { 194 | v1Parts := v1.Segments() 195 | v2Parts := v2.Segments() 196 | 197 | return v1Parts[0] == v2Parts[0] && v1Parts[1] == v2Parts[1] 198 | } 199 | 200 | func isHeads(heads map[int]*version.Version, v *version.Version) bool { 201 | for _, head := range heads { 202 | if v.Equal(head) { 203 | return true 204 | } 205 | } 206 | 207 | return false 208 | } 209 | -------------------------------------------------------------------------------- /menu/js_test.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/traefik/structor/types" 13 | ) 14 | 15 | func Test_buildJSFile(t *testing.T) { 16 | testCases := []struct { 17 | desc string 18 | branches []string 19 | versionsInfo types.VersionsInformation 20 | jsTemplate string 21 | expected string 22 | }{ 23 | { 24 | desc: "simple", 25 | branches: []string{"origin/v1.9", "origin/master", "v1.9.6", "origin/v1.10", "origin/v1.8"}, 26 | versionsInfo: types.VersionsInformation{ 27 | Current: "v1.10", 28 | Latest: "v1.9.6", 29 | Experimental: "master", 30 | }, 31 | jsTemplate: ` 32 | var foo = [ 33 | {{- range $version := .Versions }} 34 | {url: "http://localhost:8080/{{ $version.Path }}", text: "{{ $version.Text }}", selected: {{ $version.Selected }} }, 35 | {{- end}} 36 | ]; 37 | `, 38 | expected: ` 39 | var foo = [ 40 | {url: "http://localhost:8080/master", text: "Experimental", selected: false }, 41 | {url: "http://localhost:8080/v1.10", text: "v1.10 RC", selected: true }, 42 | {url: "http://localhost:8080/", text: "v1.9 Latest", selected: false }, 43 | {url: "http://localhost:8080/v1.8", text: "v1.8", selected: false }, 44 | ]; 45 | `, 46 | }, 47 | { 48 | desc: "sprig", 49 | branches: []string{"origin/v1.4", "origin/master", "v1.4.6", "origin/v1.3"}, 50 | versionsInfo: types.VersionsInformation{ 51 | Current: "v1.4", 52 | Latest: "v1.4.6", 53 | Experimental: "master", 54 | }, 55 | jsTemplate: ` 56 | var foo = [ 57 | {{- range $version := .Versions }} 58 | {{- $text := $version.Text }} 59 | {{- if eq $version.State "EXPERIMENTAL" }} 60 | {{- $latest := semver $.Latest }} 61 | {{- $text = printf "v%d.%d (unreleased)" $latest.Major (int64 1 | add $latest.Minor) }} 62 | {{- end}} 63 | {url: "http://localhost:8080/{{ $version.Path }}", text: "{{ $text }}", selected: {{ eq $version.Name $.Current }} }, 64 | {{- end}} 65 | ]; 66 | `, 67 | expected: ` 68 | var foo = [ 69 | {url: "http://localhost:8080/master", text: "v1.5 (unreleased)", selected: false }, 70 | {url: "http://localhost:8080/", text: "v1.4 Latest", selected: true }, 71 | {url: "http://localhost:8080/v1.3", text: "v1.3", selected: false }, 72 | ]; 73 | `, 74 | }, 75 | { 76 | desc: "traefik-menu.js.gotmpl - not obsolete", 77 | branches: []string{"origin/v1.9", "origin/master", "v1.9.6", "origin/v1.10", "origin/v1.8"}, 78 | versionsInfo: types.VersionsInformation{ 79 | Current: "v1.10", 80 | Latest: "v1.9.6", 81 | Experimental: "master", 82 | }, 83 | jsTemplate: func() string { 84 | data, _ := os.ReadFile("../traefik-menu.js.gotmpl") 85 | return string(data) 86 | }(), 87 | expected: func() string { 88 | data, _ := os.ReadFile("./fixtures/traefik-menu.js") 89 | return string(data) 90 | }(), 91 | }, 92 | { 93 | desc: "traefik-menu.js.gotmpl - obsolete", 94 | branches: []string{"origin/v1.9", "origin/master", "v1.9.6", "origin/v1.10", "origin/v1.8"}, 95 | versionsInfo: types.VersionsInformation{ 96 | Current: "v1.8", 97 | Latest: "v1.9.6", 98 | Experimental: "master", 99 | }, 100 | jsTemplate: func() string { 101 | data, _ := os.ReadFile("../traefik-menu.js.gotmpl") 102 | return string(data) 103 | }(), 104 | expected: func() string { 105 | data, _ := os.ReadFile("./fixtures/traefik-menu-obsolete.js") 106 | return string(data) 107 | }(), 108 | }, 109 | } 110 | 111 | for _, test := range testCases { 112 | test := test 113 | t.Run(test.desc, func(t *testing.T) { 114 | dir, err := os.MkdirTemp("", "structor-test") 115 | require.NoError(t, err) 116 | defer func() { _ = os.RemoveAll(dir) }() 117 | 118 | jsFile := filepath.Join(dir, "menu.js") 119 | 120 | err = buildJSFile(jsFile, test.versionsInfo, test.branches, test.jsTemplate) 121 | require.NoError(t, err) 122 | 123 | assert.FileExists(t, jsFile) 124 | 125 | content, err := os.ReadFile(jsFile) 126 | require.NoError(t, err) 127 | 128 | assert.Equal(t, test.expected, string(content)) 129 | }) 130 | } 131 | } 132 | 133 | func Test_buildVersions(t *testing.T) { 134 | testCases := []struct { 135 | desc string 136 | branches []string 137 | latestTagName string 138 | experimentalBranchName string 139 | currentVersion string 140 | expected []optionVersion 141 | }{ 142 | { 143 | desc: "latest", 144 | branches: []string{"origin/v1.4", "v1.4.6"}, 145 | latestTagName: "v1.4.6", 146 | currentVersion: "v1.4", 147 | expected: []optionVersion{ 148 | {Path: "", Text: "v1.4 Latest", Name: "v1.4", State: stateLatest, Selected: true}, 149 | }, 150 | }, 151 | { 152 | desc: "experimental", 153 | branches: []string{"origin/v1.4", "origin/master"}, 154 | latestTagName: "v1.4.6", 155 | experimentalBranchName: "master", 156 | currentVersion: "v1.4", 157 | expected: []optionVersion{ 158 | {Path: "master", Text: "Experimental", Name: "master", State: stateExperimental, Selected: false}, 159 | {Path: "", Text: "v1.4 Latest", Name: "v1.4", State: stateLatest, Selected: true}, 160 | }, 161 | }, 162 | { 163 | desc: "release candidate", 164 | branches: []string{"origin/v1.4", "origin/v1.5"}, 165 | latestTagName: "v1.4.6", 166 | currentVersion: "v1.4", 167 | expected: []optionVersion{ 168 | {Path: "v1.5", Text: "v1.5 RC", Name: "v1.5", State: statePreFinalRelease, Selected: false}, 169 | {Path: "", Text: "v1.4 Latest", Name: "v1.4", State: stateLatest, Selected: true}, 170 | }, 171 | }, 172 | { 173 | desc: "simple version", 174 | branches: []string{"origin/v1.3"}, 175 | latestTagName: "v1.4.6", 176 | experimentalBranchName: "master", 177 | currentVersion: "v1.4", 178 | expected: []optionVersion{ 179 | {Path: "v1.3", Text: "v1.3", Name: "v1.3", Selected: false}, 180 | }, 181 | }, 182 | { 183 | desc: "all", 184 | branches: []string{"origin/v1.4", "origin/master", "v1.4.6", "origin/v1.5", "origin/v1.3"}, 185 | latestTagName: "v1.4.6", 186 | experimentalBranchName: "master", 187 | currentVersion: "v1.4", 188 | expected: []optionVersion{ 189 | {Path: "master", Text: "Experimental", Name: "master", State: stateExperimental, Selected: false}, 190 | {Path: "v1.5", Text: "v1.5 RC", Name: "v1.5", State: statePreFinalRelease, Selected: false}, 191 | {Path: "", Text: "v1.4 Latest", Name: "v1.4", State: stateLatest, Selected: true}, 192 | {Path: "v1.3", Text: "v1.3", Name: "v1.3", State: stateObsolete, Selected: false}, 193 | }, 194 | }, 195 | { 196 | desc: "all with obsolete", 197 | branches: []string{"origin/v2.9", "origin/v2.8", "origin/master", "origin/v1.7", "v1.4.6", "origin/v1.4"}, 198 | latestTagName: "v2.9.0", 199 | experimentalBranchName: "master", 200 | currentVersion: "v1.4", 201 | expected: []optionVersion{ 202 | {Path: "master", Text: "Experimental", Name: "master", State: "EXPERIMENTAL", Selected: false}, 203 | {Path: "", Text: "v2.9 Latest", Name: "v2.9", State: "LATEST", Selected: false}, 204 | {Path: "v2.8", Text: "v2.8", Name: "v2.8", State: stateObsolete, Selected: false}, 205 | {Path: "v1.7", Text: "v1.7", Name: "v1.7", State: "", Selected: false}, 206 | {Path: "v1.4.6", Text: "v1.4.6", Name: "v1.4.6", State: stateObsolete, Selected: false}, 207 | {Path: "v1.4", Text: "v1.4", Name: "v1.4", State: stateObsolete, Selected: true}, 208 | }, 209 | }, 210 | { 211 | desc: "minor version with 2 digits", 212 | branches: []string{"origin/v2.9", "origin/v2.8", "origin/v2.10", "origin/master", "origin/v1.7", "v1.4.6", "origin/v1.4"}, 213 | latestTagName: "v2.9.0", 214 | experimentalBranchName: "master", 215 | currentVersion: "v1.4", 216 | expected: []optionVersion{ 217 | {Path: "master", Text: "Experimental", Name: "master", State: "EXPERIMENTAL", Selected: false}, 218 | {Path: "v2.10", Text: "v2.10 RC", Name: "v2.10", State: statePreFinalRelease, Selected: false}, 219 | {Path: "", Text: "v2.9 Latest", Name: "v2.9", State: "LATEST", Selected: false}, 220 | {Path: "v2.8", Text: "v2.8", Name: "v2.8", State: stateObsolete, Selected: false}, 221 | {Path: "v1.7", Text: "v1.7", Name: "v1.7", State: "", Selected: false}, 222 | {Path: "v1.4.6", Text: "v1.4.6", Name: "v1.4.6", State: stateObsolete, Selected: false}, 223 | {Path: "v1.4", Text: "v1.4", Name: "v1.4", State: stateObsolete, Selected: true}, 224 | }, 225 | }, 226 | } 227 | 228 | for _, test := range testCases { 229 | test := test 230 | t.Run(test.desc, func(t *testing.T) { 231 | t.Parallel() 232 | 233 | versions, err := buildVersions(test.currentVersion, test.branches, test.latestTagName, test.experimentalBranchName) 234 | require.NoError(t, err) 235 | 236 | assert.Equal(t, test.expected, versions) 237 | }) 238 | } 239 | } 240 | 241 | func mustReadFile(path string) []byte { 242 | bytes, err := os.ReadFile(path) 243 | if err != nil { 244 | panic(err) 245 | } 246 | return bytes 247 | } 248 | 249 | func serveFixturesContent() (string, func()) { 250 | mux := http.NewServeMux() 251 | mux.Handle("/", http.FileServer(http.Dir("./fixtures/server"))) 252 | 253 | server := httptest.NewServer(mux) 254 | 255 | return server.URL, server.Close 256 | } 257 | -------------------------------------------------------------------------------- /menu/manifest.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "github.com/traefik/structor/manifest" 5 | ) 6 | 7 | func editManifest(manif map[string]interface{}, versionJsFile, versionCSSFile string) { 8 | // Append menu JS file 9 | manifest.AppendExtraJs(manif, versionJsFile) 10 | 11 | // Append menu CSS file 12 | manifest.AppendExtraCSS(manif, versionCSSFile) 13 | 14 | // reset site URL 15 | manif["site_url"] = "" 16 | } 17 | -------------------------------------------------------------------------------- /menu/manifest_test.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/traefik/structor/manifest" 13 | ) 14 | 15 | func Test_editManifest(t *testing.T) { 16 | testCases := []struct { 17 | desc string 18 | source string 19 | versionJsFile string 20 | versionCSSFile string 21 | expected string 22 | }{ 23 | { 24 | desc: "no custom files", 25 | source: "fixtures/mkdocs.yml", 26 | versionJsFile: "", 27 | versionCSSFile: "", 28 | expected: "fixtures/test_no-custom-files.yml", 29 | }, 30 | { 31 | desc: "with JS file and existing extra", 32 | source: "fixtures/mkdocs.yml", 33 | versionJsFile: "structor-custom.js", 34 | versionCSSFile: "", 35 | expected: "fixtures/test_custom-js-1.yml", 36 | }, 37 | { 38 | desc: "with CSS file and existing extra", 39 | source: "fixtures/mkdocs.yml", 40 | versionJsFile: "", 41 | versionCSSFile: "structor-custom.css", 42 | expected: "fixtures/test_custom-css-1.yml", 43 | }, 44 | { 45 | desc: "with JS file and without extra", 46 | source: "fixtures/mkdocs_without-extra.yml", 47 | versionJsFile: "structor-custom.js", 48 | versionCSSFile: "", 49 | expected: "fixtures/test_custom-js-2.yml", 50 | }, 51 | { 52 | desc: "with CSS file and without extra", 53 | source: "fixtures/mkdocs_without-extra.yml", 54 | versionJsFile: "", 55 | versionCSSFile: "structor-custom.css", 56 | expected: "fixtures/test_custom-css-2.yml", 57 | }, 58 | } 59 | 60 | for _, test := range testCases { 61 | test := test 62 | t.Run(test.desc, func(t *testing.T) { 63 | testManifest, tearDown := setupTestManifest(test.source) 64 | defer tearDown() 65 | 66 | manif, err := manifest.Read(testManifest) 67 | require.NoError(t, err) 68 | 69 | editManifest(manif, test.versionJsFile, test.versionCSSFile) 70 | 71 | err = manifest.Write(testManifest, manif) 72 | require.NoError(t, err) 73 | 74 | assertSameContent(t, test.expected, testManifest) 75 | }) 76 | } 77 | } 78 | 79 | func setupTestManifest(src string) (string, func()) { 80 | srcManifest, err := filepath.Abs(src) 81 | if err != nil { 82 | return "", func() {} 83 | } 84 | 85 | dir, err := os.MkdirTemp("", "structor-test") 86 | if err != nil { 87 | return "", func() {} 88 | } 89 | 90 | testManifest := filepath.Join(dir, "mkdocs.yml") 91 | 92 | err = fileCopy(srcManifest, testManifest) 93 | if err != nil { 94 | return "", func() {} 95 | } 96 | 97 | return testManifest, func() { 98 | if err := os.RemoveAll(dir); err != nil { 99 | log.Println(err) 100 | } 101 | } 102 | } 103 | 104 | func assertSameContent(t *testing.T, expectedFilePath, actualFilePath string) { 105 | t.Helper() 106 | 107 | content, err := os.ReadFile(actualFilePath) 108 | require.NoError(t, err) 109 | 110 | expected, err := os.ReadFile(expectedFilePath) 111 | require.NoError(t, err) 112 | 113 | assert.Equal(t, string(expected), string(content)) 114 | } 115 | 116 | func fileCopy(src, dst string) error { 117 | info, err := os.Stat(src) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | f, err := os.Create(dst) 123 | if err != nil { 124 | return err 125 | } 126 | defer func() { _ = f.Close() }() 127 | 128 | if err = os.Chmod(f.Name(), info.Mode()); err != nil { 129 | return err 130 | } 131 | 132 | s, err := os.Open(src) 133 | if err != nil { 134 | return err 135 | } 136 | defer func() { _ = s.Close() }() 137 | 138 | _, err = io.Copy(f, s) 139 | return err 140 | } 141 | -------------------------------------------------------------------------------- /menu/menu.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/traefik/structor/file" 10 | "github.com/traefik/structor/manifest" 11 | "github.com/traefik/structor/types" 12 | ) 13 | 14 | const baseRemote = "origin/" 15 | 16 | // Content the content of menu files. 17 | type Content struct { 18 | Js []byte 19 | CSS []byte 20 | } 21 | 22 | // GetTemplateContent Gets menu template content. 23 | func GetTemplateContent(menu *types.MenuFiles) Content { 24 | var content Content 25 | 26 | if menu.HasJsFile() { 27 | jsContent, err := getMenuFileContent(menu.JsFile, menu.JsURL) 28 | if err != nil { 29 | return Content{} 30 | } 31 | content.Js = jsContent 32 | } 33 | 34 | if menu.HasCSSFile() { 35 | cssContent, err := getMenuFileContent(menu.CSSFile, menu.CSSURL) 36 | if err != nil { 37 | return Content{} 38 | } 39 | content.CSS = cssContent 40 | } 41 | 42 | return content 43 | } 44 | 45 | func getMenuFileContent(f, u string) ([]byte, error) { 46 | if len(f) > 0 { 47 | content, err := os.ReadFile(f) 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to get template menu file content: %w", err) 50 | } 51 | return content, nil 52 | } 53 | 54 | content, err := file.Download(u) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to download menu template: %w", err) 57 | } 58 | return content, nil 59 | } 60 | 61 | // Build the menu. 62 | func Build(versionsInfo types.VersionsInformation, branches []string, menuContent Content) error { 63 | manifestFile := filepath.Join(versionsInfo.CurrentPath, manifest.FileName) 64 | 65 | manif, err := manifest.Read(manifestFile) 66 | if err != nil { 67 | return fmt.Errorf("failed to read manifest %s: %w", manifestFile, err) 68 | } 69 | 70 | manifestDocsDir := manifest.GetDocsDir(manif, manifestFile) 71 | 72 | log.Printf("Using docs_dir from manifest: %s", manifestDocsDir) 73 | 74 | manifestJsFilePath, err := writeJsFile(manifestDocsDir, menuContent, versionsInfo, branches) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | manifestCSSFilePath, err := writeCSSFile(manifestDocsDir, menuContent) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | editManifest(manif, manifestJsFilePath, manifestCSSFilePath) 85 | 86 | err = manifest.Write(manifestFile, manif) 87 | if err != nil { 88 | return fmt.Errorf("error when edit MkDocs manifest: %w", err) 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /menu/menu_test.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/traefik/structor/file" 11 | "github.com/traefik/structor/manifest" 12 | "github.com/traefik/structor/types" 13 | ) 14 | 15 | func TestGetTemplateContent(t *testing.T) { 16 | serverURL, teardown := serveFixturesContent() 17 | defer teardown() 18 | 19 | testCases := []struct { 20 | desc string 21 | menuFiles *types.MenuFiles 22 | expected Content 23 | }{ 24 | { 25 | desc: "no files", 26 | menuFiles: &types.MenuFiles{}, 27 | expected: Content{}, 28 | }, 29 | { 30 | desc: "JS URL", 31 | menuFiles: &types.MenuFiles{ 32 | JsURL: serverURL + "/test-menu.js.gotmpl", 33 | }, 34 | expected: Content{ 35 | Js: mustReadFile("./fixtures/server/test-menu.js.gotmpl"), 36 | }, 37 | }, 38 | { 39 | desc: "JS URL missing file", 40 | menuFiles: &types.MenuFiles{ 41 | JsURL: serverURL + "/missing-menu.js.gotmpl", 42 | }, 43 | expected: Content{}, 44 | }, 45 | { 46 | desc: "JS local file", 47 | menuFiles: &types.MenuFiles{ 48 | JsFile: "./fixtures/test-menu.js.gotmpl", 49 | }, 50 | expected: Content{ 51 | Js: mustReadFile("./fixtures/test-menu.js.gotmpl"), 52 | }, 53 | }, 54 | { 55 | desc: "JS local file missing", 56 | menuFiles: &types.MenuFiles{ 57 | JsFile: "./fixtures/missing-menu.js.gotmpl", 58 | }, 59 | expected: Content{}, 60 | }, 61 | { 62 | desc: "CSS URL", 63 | menuFiles: &types.MenuFiles{ 64 | CSSURL: serverURL + "/test-menu.css.gotmpl", 65 | }, 66 | expected: Content{ 67 | CSS: mustReadFile("./fixtures/server/test-menu.css.gotmpl"), 68 | }, 69 | }, 70 | { 71 | desc: "CSS URL missing file", 72 | menuFiles: &types.MenuFiles{ 73 | CSSURL: serverURL + "/missing-menu.css.gotmpl", 74 | }, 75 | expected: Content{}, 76 | }, 77 | { 78 | desc: "CSS local file", 79 | menuFiles: &types.MenuFiles{ 80 | CSSFile: "./fixtures/test-menu.css.gotmpl", 81 | }, 82 | expected: Content{ 83 | CSS: mustReadFile("./fixtures/test-menu.css.gotmpl"), 84 | }, 85 | }, 86 | { 87 | desc: "CSS local file missing", 88 | menuFiles: &types.MenuFiles{ 89 | CSSFile: "./fixtures/missing-menu.css.gotmpl", 90 | }, 91 | expected: Content{}, 92 | }, 93 | } 94 | 95 | for _, test := range testCases { 96 | test := test 97 | t.Run(test.desc, func(t *testing.T) { 98 | content := GetTemplateContent(test.menuFiles) 99 | 100 | assert.Equal(t, test.expected, content) 101 | }) 102 | } 103 | } 104 | 105 | func TestBuild(t *testing.T) { 106 | projectDir, err := os.MkdirTemp("", "structor-test") 107 | require.NoError(t, err) 108 | defer func() { _ = os.RemoveAll(projectDir) }() 109 | 110 | manifestFile := filepath.Join(projectDir, manifest.FileName) 111 | err = file.Copy(filepath.Join(".", "fixtures", "mkdocs.yml"), manifestFile) 112 | require.NoError(t, err) 113 | 114 | versionsInfo := types.VersionsInformation{ 115 | Latest: "v1.7.9", 116 | CurrentPath: projectDir, 117 | } 118 | 119 | var branches []string 120 | 121 | menuContent := Content{ 122 | Js: mustReadFile("./fixtures/test-menu.js.gotmpl"), 123 | CSS: mustReadFile("./fixtures/test-menu.css.gotmpl"), 124 | } 125 | 126 | err = Build(versionsInfo, branches, menuContent) 127 | require.NoError(t, err) 128 | 129 | assert.FileExists(t, manifestFile) 130 | assert.FileExists(t, filepath.Join(projectDir, "docs", "theme", "js", menuJsFileName)) 131 | assert.FileExists(t, filepath.Join(projectDir, "docs", "theme", "css", menuCSSFileName)) 132 | } 133 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Messor Structor: Manage multiple documentation versions with Mkdocs. 2 | 3 | [![GitHub release](https://img.shields.io/github/release/traefik/structor.svg)](https://github.com/traefik/structor/releases/latest) 4 | [![Build Status](https://github.com/traefik/structor/workflows/Main/badge.svg?branch=master)](https://github.com/traefik/structor/actions) 5 | 6 | Structor use git branches to create the versions of a documentation, only works with Mkdocs. 7 | 8 | To use Structor a project must respect [semver](https://semver.org) and creates a git branch for each `MINOR` and `MAJOR` version. 9 | 10 | Created for [Traefik](https://github.com/traefik/traefik) and used by: 11 | 12 | * [Traefik](https://github.com/traefik/traefik) for https://docs.traefik.io 13 | * [Redis Labs](https://redislabs.com/) https://redislabs.com/community/oss-projects/ 14 | * [JanusGraph](https://github.com/JanusGraph/janusgraph) for https://docs.janusgraph.org 15 | * [ONOS Project](https://github.com/onosproject) for https://docs.onosproject.org 16 | * [Drasyl](https://gitlab.com/drasyl/drasyl) for https://docs.drasyl.org 17 | 18 | ## Prerequisites 19 | 20 | * [git](https://git-scm.com/) 21 | * [Docker](https://www.docker.com/) 22 | * `requirements.txt`, `mkdocs.yml`, and a Dockerfile. 23 | 24 | ## Description 25 | 26 | For the following git graph: 27 | 28 | ``` 29 | * gaaaaaaag - (branch master) commit 30 | | * faaaaaaaf - (branch v1.2) commit 31 | | * eaaaaaaae - commit 32 | |/ 33 | * haaaaaaah - commit 34 | | * kaaaaaaak - (branch v1.1) commit 35 | | * jaaaaaaaj - commit 36 | |/ 37 | * iaaaaaaai - commit 38 | | * daaaaaaad - (branch v1.0) commit 39 | | * caaaaaaac - commit 40 | |/ 41 | * baaaaaaab - commit 42 | * aaaaaaaaa - initial commit 43 | ``` 44 | 45 | Structor generates the following files structure: 46 | 47 | ``` 48 | . (latest, branch v1.2) 49 | ├── index.html 50 | ├── ... 51 | ├── v1.0 (branch v1.0) 52 | │ ├── index.html 53 | │ └── ... 54 | ├── v1.1 (branch v1.1) 55 | │ ├── index.html 56 | │ └── ... 57 | └── v1.2 (branch v1.2) 58 | ├── index.html 59 | └── ... 60 | ``` 61 | 62 | If the content from `.` is served on `mydoc.com`, documentation will be available at the following URLs: 63 | 64 | - `http://mydoc.com` (latest, branch v1.2) 65 | - `http://mydoc.com/v1.0` (branch v1.0) 66 | - `http://mydoc.com/v1.1` (branch v1.1) 67 | - `http://mydoc.com/v1.2` (branch v1.2) 68 | 69 | The multi version menu is created from templates provided by the following options: 70 | 71 | - `--menu.js-url` (or `--menu.js-file`) 72 | - `--menu.css-url` (or `--menu.css-file`) 73 | 74 | ## Configuration 75 | 76 | ```yaml 77 | Messor Structor: Manage multiple documentation versions with Mkdocs. 78 | 79 | Usage: 80 | structor [flags] 81 | structor [command] 82 | 83 | Available Commands: 84 | help Help about any command 85 | version Display version 86 | 87 | Flags: 88 | --debug Debug mode. 89 | --dockerfile-name string Search and use this Dockerfile in the repository (in './docs/' or in './') for building documentation. (default "docs.Dockerfile") 90 | -d, --dockerfile-url string Use this Dockerfile when --dockerfile-name is not found. Can be a file path. [required] 91 | --exclude strings Exclude branches from the documentation generation. 92 | --exp-branch string Build a branch as experimental. 93 | --force-edit-url Add a dedicated edition URL for each version. 94 | -h, --help help for structor 95 | --image-name string Docker image name. (default "doc-site") 96 | --menu.css-file string File path of the template of the CSS file use for the multi version menu. 97 | --menu.css-url string URL of the template of the CSS file use for the multi version menu. 98 | --menu.js-file string File path of the template of the JS file use for the multi version menu. 99 | --menu.js-url string URL of the template of the JS file use for the multi version menu. 100 | --no-cache Set to 'true' to disable the Docker build cache. 101 | -o, --owner string Repository owner. [required] 102 | -r, --repo-name string Repository name. [required] 103 | --rqts-url string Use this requirements.txt to merge with the current requirements.txt. Can be a file path. 104 | --version version for structor 105 | ``` 106 | 107 | The environment variable `STRUCTOR_LATEST_TAG` allow to override the latest tag name obtains from GitHub. 108 | 109 | The [sprig](http://masterminds.github.io/sprig/) functions for Go templates can be used inside the JS template file. 110 | 111 | ## Download / CI Integration 112 | 113 | ```bash 114 | curl -sfL https://raw.githubusercontent.com/traefik/structor/master/godownloader.sh | bash -s -- -b $GOPATH/bin v1.7.0 115 | ``` 116 | 117 | 128 | 129 | ## Examples 130 | 131 | A simple example is available on the repository https://github.com/mmatur/structor-sample. 132 | 133 | With menu template URL: 134 | 135 | ```shell 136 | sudo ./structor -o traefik -r traefik \ 137 | --dockerfile-url="https://raw.githubusercontent.com/traefik/traefik/master/docs.Dockerfile" \ 138 | --menu.js-url="https://raw.githubusercontent.com/traefik/structor/master/traefik-menu.js.gotmpl" \ 139 | --exp-branch=master --debug 140 | ``` 141 | 142 | With local menu template file: 143 | 144 | ```shell 145 | sudo ./structor -o traefik -r traefik \ 146 | --dockerfile-url="https://raw.githubusercontent.com/traefik/traefik/master/docs.Dockerfile" \ 147 | --menu.js-file="~/go/src/github.com/traefik/structor/traefik-menu.js.gotmpl" \ 148 | --exp-branch=master --debug 149 | ``` 150 | 151 | ## The Mymirca colony 152 | 153 | - [Myrmica Lobicornis](https://github.com/traefik/lobicornis) 🐜: Update and merge pull requests. 154 | - [Myrmica Aloba](https://github.com/traefik/aloba) 🐜: Add labels and milestone on pull requests and issues. 155 | - [Messor Structor](https://github.com/traefik/structor) 🐜: Manage multiple documentation versions with Mkdocs. 156 | - [Lasius Mixtus](https://github.com/traefik/mixtus) 🐜: Publish documentation to a GitHub repository from another. 157 | - [Myrmica Bibikoffi](https://github.com/traefik/bibikoffi) 🐜: Closes stale issues 158 | - [Chalepoxenus Kutteri](https://github.com/traefik/kutteri) 🐜: Track a GitHub repository and publish on Slack. 159 | - [Myrmica Gallienii](https://github.com/traefik/gallienii) 🐜: Keep Forks Synchronized 160 | 161 | ## What does Messor Structor mean? 162 | 163 | ![Messor Structor](http://www.antwiki.org/wiki/images/8/8d/Messor_structor_antweb1008070_h_1_high.jpg) 164 | -------------------------------------------------------------------------------- /repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/ldez/go-git-cmd-wrapper/branch" 9 | "github.com/ldez/go-git-cmd-wrapper/git" 10 | gTypes "github.com/ldez/go-git-cmd-wrapper/types" 11 | "github.com/ldez/go-git-cmd-wrapper/worktree" 12 | ) 13 | 14 | // CreateWorkTree create a worktree for a specific version. 15 | func CreateWorkTree(path, version string, debug bool) error { 16 | _, err := git.Worktree(worktree.Add(path, version), git.Debugger(debug)) 17 | if err != nil { 18 | return fmt.Errorf("failed to add worktree on path %s for version %s: %w", path, version, err) 19 | } 20 | 21 | return nil 22 | } 23 | 24 | // ListBranches List all remotes branches. 25 | func ListBranches(debug bool) ([]string, error) { 26 | branchesRaw, err := git.Branch(branch.Remotes, branch.List, branchVersionPattern, git.Debugger(debug)) 27 | if err != nil { 28 | return nil, fmt.Errorf("failed to retrieves branches: %w", err) 29 | } 30 | 31 | var branches []string 32 | for _, branchName := range strings.Split(branchesRaw, "\n") { 33 | trimmedName := strings.TrimSpace(branchName) 34 | if trimmedName != "" { 35 | branches = append(branches, trimmedName) 36 | } 37 | } 38 | sort.Sort(sort.Reverse(sort.StringSlice(branches))) 39 | return branches, nil 40 | } 41 | 42 | func branchVersionPattern(g *gTypes.Cmd) { 43 | g.AddOptions("origin\\/v*") 44 | } 45 | -------------------------------------------------------------------------------- /repository/repository_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/ldez/go-git-cmd-wrapper/git" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestListBranches(t *testing.T) { 15 | git.CmdExecutor = func(name string, debug bool, args ...string) (string, error) { 16 | if debug { 17 | log.Println(name, strings.Join(args, " ")) 18 | } 19 | return ` 20 | origin/v1.3 21 | origin/v1.1 22 | origin/v1.2 23 | `, nil 24 | } 25 | 26 | branches, err := ListBranches(true) 27 | require.NoError(t, err) 28 | 29 | expected := []string{"origin/v1.3", "origin/v1.2", "origin/v1.1"} 30 | assert.Equal(t, expected, branches) 31 | } 32 | 33 | func TestListBranches_error(t *testing.T) { 34 | git.CmdExecutor = func(name string, debug bool, args ...string) (string, error) { 35 | if debug { 36 | log.Println(name, strings.Join(args, " ")) 37 | } 38 | return "", errors.New("fail") 39 | } 40 | 41 | _, err := ListBranches(true) 42 | assert.EqualError(t, err, "failed to retrieves branches: fail") 43 | } 44 | -------------------------------------------------------------------------------- /requirements-override.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traefik/structor/b9d35a69d22cbe64ae8ecfdd0fdb7173376ceb39/requirements-override.txt -------------------------------------------------------------------------------- /requirements/fixtures/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==0.17.5 2 | pymdown-extensions==4.12 3 | mkdocs-bootswatch==0.5.0 4 | mkdocs-material==2.9.4 5 | -------------------------------------------------------------------------------- /requirements/requirements.go: -------------------------------------------------------------------------------- 1 | package requirements 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/traefik/structor/file" 13 | "github.com/traefik/structor/types" 14 | ) 15 | 16 | const filename = "requirements.txt" 17 | 18 | // Check return an error if the requirements file is not found in the doc root directory. 19 | func Check(docRoot string) error { 20 | _, err := os.Stat(filepath.Join(docRoot, filename)) 21 | return err 22 | } 23 | 24 | // GetContent Gets the content of the "requirements.txt". 25 | func GetContent(requirementsPath string) ([]byte, error) { 26 | if requirementsPath == "" { 27 | return nil, nil 28 | } 29 | 30 | if _, errStat := os.Stat(requirementsPath); errStat == nil { 31 | content, err := os.ReadFile(requirementsPath) 32 | if err != nil { 33 | return nil, fmt.Errorf("failed to read Requirements file: %w", err) 34 | } 35 | return content, nil 36 | } 37 | 38 | content, err := file.Download(requirementsPath) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to download Requirements file: %w", err) 41 | } 42 | return content, nil 43 | } 44 | 45 | // Build Builds a "requirements.txt" file. 46 | func Build(versionsInfo types.VersionsInformation, customContent []byte) error { 47 | if len(customContent) == 0 { 48 | return nil 49 | } 50 | 51 | requirementsPath := filepath.Join(versionsInfo.CurrentPath, filename) 52 | 53 | baseContent, err := os.ReadFile(requirementsPath) 54 | if err != nil { 55 | return fmt.Errorf("unable to read %s: %w", requirementsPath, err) 56 | } 57 | 58 | reqBase, err := parse(baseContent) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | reqCustom, err := parse(customContent) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | // merge 69 | for key, value := range reqCustom { 70 | reqBase[key] = value 71 | } 72 | 73 | f, err := os.Create(requirementsPath) 74 | if err != nil { 75 | return fmt.Errorf("failed to create requirement file %s: %w", requirementsPath, err) 76 | } 77 | defer safeClose(f.Close) 78 | 79 | var sortedKeys []string 80 | for key := range reqBase { 81 | sortedKeys = append(sortedKeys, key) 82 | } 83 | sort.Strings(sortedKeys) 84 | 85 | for _, key := range sortedKeys { 86 | fmt.Fprintf(f, "%s%s\n", key, reqBase[key]) 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func parse(content []byte) (map[string]string, error) { 93 | exp := regexp.MustCompile(`([\w-]+)([=|><].+)`) 94 | 95 | result := make(map[string]string) 96 | 97 | lines := strings.Split(string(content), "\n") 98 | for _, line := range lines { 99 | if len(line) > 0 { 100 | submatch := exp.FindStringSubmatch(line) 101 | if len(submatch) != 3 { 102 | return nil, fmt.Errorf("invalid line format: %s", line) 103 | } 104 | 105 | result[submatch[1]] = submatch[2] 106 | } 107 | } 108 | 109 | return result, nil 110 | } 111 | 112 | func safeClose(fn func() error) { 113 | if err := fn(); err != nil { 114 | log.Println(err) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /requirements/requirements_test.go: -------------------------------------------------------------------------------- 1 | package requirements 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/traefik/structor/file" 13 | "github.com/traefik/structor/types" 14 | ) 15 | 16 | func TestCheck(t *testing.T) { 17 | workingDirBasePath, err := os.MkdirTemp("", "structor-test") 18 | defer func() { _ = os.RemoveAll(workingDirBasePath) }() 19 | require.NoError(t, err) 20 | 21 | testCases := []struct { 22 | desc string 23 | workingDirectory string 24 | workingDirectoryFiles []string 25 | expectedErrorMessage string 26 | }{ 27 | { 28 | desc: "working case with requirements.txt in the provided directory", 29 | workingDirectory: filepath.Join(workingDirBasePath, "requirements-found"), 30 | workingDirectoryFiles: []string{"mkdocs.yml", "requirements.txt"}, 31 | }, 32 | { 33 | desc: "error case with no requirements.txt file found in the provided directory", 34 | workingDirectory: filepath.Join(workingDirBasePath, "requirements-not-found"), 35 | workingDirectoryFiles: []string{"mkdocs.yml"}, 36 | expectedErrorMessage: "stat " + workingDirBasePath + "/requirements-not-found/requirements.txt: no such file or directory", 37 | }, 38 | } 39 | 40 | for _, test := range testCases { 41 | test := test 42 | t.Run(test.desc, func(t *testing.T) { 43 | if test.workingDirectory != "" { 44 | err = os.MkdirAll(test.workingDirectory, os.ModePerm) 45 | require.NoError(t, err) 46 | } 47 | 48 | if test.workingDirectoryFiles != nil { 49 | for _, repositoryFile := range test.workingDirectoryFiles { 50 | absoluteRepositoryFilePath := filepath.Join(test.workingDirectory, repositoryFile) 51 | 52 | err := os.MkdirAll(filepath.Dir(absoluteRepositoryFilePath), os.ModePerm) 53 | require.NoError(t, err) 54 | 55 | _, err = os.Create(absoluteRepositoryFilePath) 56 | require.NoError(t, err) 57 | } 58 | } 59 | 60 | resultingError := Check(test.workingDirectory) 61 | 62 | if test.expectedErrorMessage != "" { 63 | assert.EqualError(t, resultingError, test.expectedErrorMessage) 64 | } else { 65 | require.NoError(t, resultingError) 66 | } 67 | }) 68 | } 69 | } 70 | 71 | func TestGetContent(t *testing.T) { 72 | serverURL, teardown := serveFixturesContent() 73 | defer teardown() 74 | 75 | testCases := []struct { 76 | desc string 77 | requirementsPath string 78 | expected string 79 | }{ 80 | { 81 | desc: "empty path", 82 | requirementsPath: "", 83 | expected: "", 84 | }, 85 | { 86 | desc: "local file", 87 | requirementsPath: filepath.Join(".", "fixtures", "requirements.txt"), 88 | expected: string(mustReadFile(filepath.Join(".", "fixtures", "requirements.txt"))), 89 | }, 90 | { 91 | desc: "remote file", 92 | requirementsPath: serverURL + "/requirements.txt", 93 | expected: string(mustReadFile(filepath.Join(".", "fixtures", "requirements.txt"))), 94 | }, 95 | } 96 | 97 | for _, test := range testCases { 98 | test := test 99 | t.Run(test.desc, func(t *testing.T) { 100 | content, err := GetContent(test.requirementsPath) 101 | require.NoError(t, err) 102 | 103 | assert.Equal(t, test.expected, string(content)) 104 | }) 105 | } 106 | } 107 | 108 | func TestGetContent_Fail(t *testing.T) { 109 | serverURL, teardown := serveFixturesContent() 110 | defer teardown() 111 | 112 | testCases := []struct { 113 | desc string 114 | requirementsPath string 115 | }{ 116 | { 117 | desc: "local file", 118 | requirementsPath: filepath.Join(".", "fixtures", "missing.txt"), 119 | }, 120 | { 121 | desc: "remote file", 122 | requirementsPath: serverURL + "/missing.txt", 123 | }, 124 | } 125 | 126 | for _, test := range testCases { 127 | test := test 128 | t.Run(test.desc, func(t *testing.T) { 129 | _, err := GetContent(test.requirementsPath) 130 | require.Error(t, err) 131 | }) 132 | } 133 | } 134 | 135 | func TestBuild(t *testing.T) { 136 | testCases := []struct { 137 | desc string 138 | customContent string 139 | expected string 140 | }{ 141 | { 142 | desc: "no custom content", 143 | expected: `mkdocs==0.17.5 144 | pymdown-extensions==4.12 145 | mkdocs-bootswatch==0.5.0 146 | mkdocs-material==2.9.4 147 | `, 148 | }, 149 | { 150 | desc: "merge", 151 | customContent: ` 152 | mkdocs==0.17.6 153 | `, 154 | expected: `mkdocs==0.17.6 155 | mkdocs-bootswatch==0.5.0 156 | mkdocs-material==2.9.4 157 | pymdown-extensions==4.12 158 | `, 159 | }, 160 | { 161 | desc: "add", 162 | customContent: ` 163 | foo=0.17.6 164 | `, 165 | expected: `foo=0.17.6 166 | mkdocs==0.17.5 167 | mkdocs-bootswatch==0.5.0 168 | mkdocs-material==2.9.4 169 | pymdown-extensions==4.12 170 | `, 171 | }, 172 | } 173 | 174 | for _, test := range testCases { 175 | test := test 176 | t.Run(test.desc, func(t *testing.T) { 177 | t.Parallel() 178 | 179 | dir, err := os.MkdirTemp("", "structor-test") 180 | require.NoError(t, err) 181 | defer func() { _ = os.RemoveAll(dir) }() 182 | 183 | requirementPath := filepath.Join(dir, filename) 184 | err = file.Copy(filepath.Join(".", "fixtures", filename), requirementPath) 185 | require.NoError(t, err) 186 | 187 | versionsInfo := types.VersionsInformation{ 188 | CurrentPath: dir, 189 | } 190 | 191 | err = Build(versionsInfo, []byte(test.customContent)) 192 | require.NoError(t, err) 193 | 194 | require.FileExists(t, requirementPath) 195 | content, err := os.ReadFile(requirementPath) 196 | require.NoError(t, err) 197 | 198 | assert.Equal(t, test.expected, string(content)) 199 | }) 200 | } 201 | } 202 | 203 | func Test_parse(t *testing.T) { 204 | reqts := ` 205 | pkg1<5 206 | pkg2==3 207 | pkg3>=1.0 208 | 209 | pkg4-a>=1.0,<=2.0 210 | pkg5<1.3 211 | ` 212 | 213 | content, err := parse([]byte(reqts)) 214 | require.NoError(t, err) 215 | 216 | expected := map[string]string{ 217 | "pkg1": "<5", 218 | "pkg2": "==3", 219 | "pkg3": ">=1.0", 220 | "pkg4-a": ">=1.0,<=2.0", 221 | "pkg5": "<1.3", 222 | } 223 | 224 | assert.Equal(t, expected, content) 225 | } 226 | 227 | func mustReadFile(path string) []byte { 228 | bytes, err := os.ReadFile(path) 229 | if err != nil { 230 | panic(err) 231 | } 232 | return bytes 233 | } 234 | 235 | func serveFixturesContent() (string, func()) { 236 | mux := http.NewServeMux() 237 | mux.Handle("/", http.FileServer(http.Dir("./fixtures"))) 238 | 239 | server := httptest.NewServer(mux) 240 | 241 | return server.URL, server.Close 242 | } 243 | -------------------------------------------------------------------------------- /structor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/cobra/doc" 10 | "github.com/traefik/structor/core" 11 | "github.com/traefik/structor/types" 12 | ) 13 | 14 | const ( 15 | defaultDockerImageName = "doc-site" 16 | defaultDockerfileName = "docs.Dockerfile" 17 | ) 18 | 19 | func main() { 20 | cfg := &types.Configuration{ 21 | DockerImageName: defaultDockerImageName, 22 | DockerfileName: defaultDockerfileName, 23 | NoCache: false, 24 | Menu: &types.MenuFiles{}, 25 | } 26 | 27 | rootCmd := &cobra.Command{ 28 | Use: "structor", 29 | Short: "Messor Structor: Manage multiple documentation versions with Mkdocs.", 30 | Long: `Messor Structor: Manage multiple documentation versions with Mkdocs.`, 31 | Version: version, 32 | PreRunE: func(_ *cobra.Command, _ []string) error { 33 | if cfg.Debug { 34 | log.Printf("Run Structor command with config : %+v", cfg) 35 | } 36 | 37 | if cfg.DockerImageName == "" { 38 | log.Printf("'image-name' is undefined, fallback to %s.", defaultDockerImageName) 39 | cfg.DockerImageName = defaultDockerImageName 40 | } 41 | 42 | return validateConfig(cfg) 43 | }, 44 | RunE: func(_ *cobra.Command, _ []string) error { 45 | return core.Execute(cfg) 46 | }, 47 | } 48 | 49 | flags := rootCmd.Flags() 50 | flags.StringVarP(&cfg.Owner, "owner", "o", "", "Repository owner. [required]") 51 | flags.StringVarP(&cfg.RepositoryName, "repo-name", "r", "", "Repository name. [required]") 52 | 53 | flags.BoolVar(&cfg.Debug, "debug", false, "Debug mode.") 54 | 55 | flags.StringVarP(&cfg.DockerfileURL, "dockerfile-url", "d", "", "Use this Dockerfile when --dockerfile-name is not found. Can be a file path. [required]") 56 | flags.StringVar(&cfg.DockerfileURL, "dockerfile-name", defaultDockerfileName, "Search and use this Dockerfile in the repository (in './docs/' or in './') for building documentation.") 57 | flags.StringVar(&cfg.DockerImageName, "image-name", defaultDockerImageName, "Docker image name.") 58 | flags.BoolVar(&cfg.NoCache, "no-cache", false, "Set to 'true' to disable the Docker build cache.") 59 | 60 | flags.StringVar(&cfg.ExperimentalBranchName, "exp-branch", "", "Build a branch as experimental.") 61 | flags.StringSliceVar(&cfg.ExcludedBranches, "exclude", nil, "Exclude branches from the documentation generation.") 62 | 63 | flags.BoolVar(&cfg.ForceEditionURI, "force-edit-url", false, "Add a dedicated edition URL for each version.") 64 | flags.StringVar(&cfg.RequirementsURL, "rqts-url", "", "Use this requirements.txt to merge with the current requirements.txt. Can be a file path.") 65 | 66 | flags.StringVar(&cfg.Menu.JsURL, "menu.js-url", "", "URL of the template of the JS file use for the multi version menu.") 67 | flags.StringVar(&cfg.Menu.JsFile, "menu.js-file", "", "File path of the template of the JS file use for the multi version menu.") 68 | flags.StringVar(&cfg.Menu.CSSURL, "menu.css-url", "", "URL of the template of the CSS file use for the multi version menu.") 69 | flags.StringVar(&cfg.Menu.CSSFile, "menu.css-file", "", "File path of the template of the CSS file use for the multi version menu.") 70 | 71 | docCmd := &cobra.Command{ 72 | Use: "doc", 73 | Short: "Generate documentation", 74 | Hidden: true, 75 | RunE: func(cmd *cobra.Command, args []string) error { 76 | return doc.GenMarkdownTree(rootCmd, "./docs") 77 | }, 78 | } 79 | 80 | rootCmd.AddCommand(docCmd) 81 | 82 | versionCmd := &cobra.Command{ 83 | Use: "version", 84 | Short: "Display version", 85 | Run: func(_ *cobra.Command, _ []string) { 86 | displayVersion(rootCmd.Name()) 87 | }, 88 | } 89 | 90 | rootCmd.AddCommand(versionCmd) 91 | 92 | if err := rootCmd.Execute(); err != nil { 93 | fmt.Printf("failed to execute: %v\n", err) 94 | os.Exit(1) 95 | } 96 | } 97 | 98 | func validateConfig(config *types.Configuration) error { 99 | err := required(config.DockerfileURL, "dockerfile-url") 100 | if err != nil { 101 | return err 102 | } 103 | err = required(config.Owner, "owner") 104 | if err != nil { 105 | return err 106 | } 107 | return required(config.RepositoryName, "repo-name") 108 | } 109 | 110 | func required(field, fieldName string) error { 111 | if field == "" { 112 | return fmt.Errorf("%s is mandatory", fieldName) 113 | } 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /traefik-menu.js.gotmpl: -------------------------------------------------------------------------------- 1 | var versions = [ 2 | {{- range $version := .Versions }} 3 | {{- $text := $version.Text }} 4 | {{- if eq $version.State "PRE_FINAL_RELEASE" }} 5 | {{- $text = printf "%s (RC)" .Name }} 6 | {{- end}} 7 | {path: "{{ $version.Path }}", text: "{{ $text }}", selected: {{ eq $version.Name $.Current }} }, 8 | {{- end}} 9 | ]; 10 | 11 | {{- range $version := .Versions }} 12 | {{ if and (eq $version.Name $.Current) (eq $version.State "OBSOLETE") }} 13 | function createBanner(parentElem, versions) { 14 | if (!parentElem || window.location.host !== "doc.traefik.io") { 15 | return; 16 | } 17 | 18 | const products = { 19 | traefik: { 20 | color: '#2aa2c1', 21 | backgroundColor: '#2aa2c11a', 22 | fullName: 'Traefik Proxy', 23 | }, 24 | 'traefik-enterprise': { 25 | color: '#337fe6', 26 | backgroundColor: '#337fe61a', 27 | fullName: 'Traefik Enterprise', 28 | }, 29 | 'traefik-mesh': { 30 | color: '#be46dd', 31 | backgroundColor: '#be46dd1a', 32 | fullName: 'Traefik Mesh', 33 | }, 34 | } 35 | 36 | const [,productName] = window.location.pathname.split('/'); 37 | const currentProduct = products[productName]; 38 | const currentVersion = versions.find(v => v.selected); 39 | const preExistentBanner = document.getElementById('outdated-doc-banner'); 40 | 41 | if (!currentProduct || !currentVersion || !!preExistentBanner) return; 42 | 43 | const cssCode = ` 44 | #obsolete-banner { 45 | display: flex; 46 | width: 100%; 47 | align-items: center; 48 | justify-content: center; 49 | max-width: 1274px; 50 | margin: 0 auto; 51 | } 52 | #obsolete-banner .obsolete-banner-content { 53 | display: flex; 54 | align-items: center; 55 | height: 40px; 56 | margin: 24px; 57 | padding: 11px 16px; 58 | border-radius: 8px; 59 | background-color: ${currentProduct.backgroundColor}; 60 | gap: 16px; 61 | font-family: Rubik, sans-serif; 62 | font-size: 14px; 63 | color: ${currentProduct.color}; 64 | box-sizing: border-box; 65 | width: 100%; 66 | } 67 | #obsolete-banner .obsolete-banner-content strong { font-weight: bold; } 68 | #obsolete-banner .obsolete-banner-content a { color: ${currentProduct.color}; text-decoration: none; } 69 | #obsolete-banner .obsolete-banner-content a:hover { text-decoration: underline; } 70 | #obsolete-banner .obsolete-banner-content p { margin: 0; } 71 | ` 72 | 73 | const banner = document.createElement('div'); 74 | banner.id = 'obsolete-banner'; 75 | banner.innerHTML = ` 76 |
77 | OLD VERSION 78 |

79 | You're looking at documentation for ${currentProduct.fullName} ${currentVersion.text}. 80 | Click here to see the latest version. → 81 |

82 |
83 | `; 84 | 85 | // Append HTML 86 | parentElem.prepend(banner); 87 | 88 | // Append Styling 89 | const [head] = document.getElementsByTagName("head"); 90 | if (!document.getElementById("obsolete-banner-style")) { 91 | const styleElem = document.createElement("style"); 92 | styleElem.id = "obsolete-banner-style"; 93 | 94 | if (styleElem.styleSheet) { 95 | styleElem.styleSheet.cssText = cssCode; 96 | } else { 97 | styleElem.appendChild(document.createTextNode(cssCode)); 98 | } 99 | 100 | head.appendChild(styleElem); 101 | } 102 | } 103 | 104 | function addBannerMaterial(versions) { 105 | const elem = document.querySelector('body > div.md-container'); 106 | createBanner(elem, versions) 107 | } 108 | 109 | function addBannerUnited() { 110 | const elem = document.querySelector('body > div.container'); 111 | createBanner(elem, versions) 112 | } 113 | {{- end}} 114 | {{- end}} 115 | 116 | // Material theme 117 | 118 | function addMaterialMenu(elt, versions) { 119 | const current = versions.find(function (value) { 120 | return value.selected; 121 | }) 122 | 123 | const rootLi = document.createElement('li'); 124 | rootLi.classList.add('md-nav__item'); 125 | rootLi.classList.add('md-nav__item--nested'); 126 | 127 | const input = document.createElement('input'); 128 | input.classList.add('md-toggle'); 129 | input.classList.add('md-nav__toggle'); 130 | input.setAttribute('data-md-toggle', 'nav-10000000'); 131 | input.id = "nav-10000000"; 132 | input.type = 'checkbox'; 133 | 134 | rootLi.appendChild(input); 135 | 136 | const lbl01 = document.createElement('label'); 137 | lbl01.classList.add('md-nav__link'); 138 | lbl01.setAttribute('for', 'nav-10000000'); 139 | 140 | const spanTitle01 = document.createElement('span'); 141 | spanTitle01.classList.add('md-nav__item-title'); 142 | spanTitle01.textContent = current.text+ " "; 143 | 144 | lbl01.appendChild(spanTitle01); 145 | 146 | const spanIcon01 = document.createElement('span'); 147 | spanIcon01.classList.add('md-nav__icon'); 148 | spanIcon01.classList.add('md-icon'); 149 | 150 | lbl01.appendChild(spanIcon01); 151 | 152 | rootLi.appendChild(lbl01); 153 | 154 | const nav = document.createElement('nav') 155 | nav.classList.add('md-nav'); 156 | nav.setAttribute('data-md-component','collapsible'); 157 | nav.setAttribute('aria-label', current.text); 158 | nav.setAttribute('data-md-level','1'); 159 | 160 | rootLi.appendChild(nav); 161 | 162 | const lbl02 = document.createElement('label'); 163 | lbl02.classList.add('md-nav__title'); 164 | lbl02.setAttribute('for', 'nav-10000000'); 165 | lbl02.textContent = current.text + " "; 166 | 167 | const spanIcon02 = document.createElement('span'); 168 | spanIcon02.classList.add('md-nav__icon'); 169 | spanIcon02.classList.add('md-icon'); 170 | 171 | lbl02.appendChild(spanIcon02); 172 | 173 | nav.appendChild(lbl02); 174 | 175 | const ul = document.createElement('ul'); 176 | ul.classList.add('md-nav__list'); 177 | ul.setAttribute('data-md-scrollfix',''); 178 | 179 | nav.appendChild(ul); 180 | 181 | for (let i = 0; i < versions.length; i++) { 182 | const li = document.createElement('li'); 183 | li.classList.add('md-nav__item'); 184 | 185 | ul.appendChild(li); 186 | 187 | const a = document.createElement('a'); 188 | a.classList.add('md-nav__link'); 189 | if (versions[i].selected) { 190 | a.classList.add('md-nav__link--active'); 191 | } 192 | a.href = window.location.protocol + "//" + window.location.host + "/"; 193 | if (window.location.host === "doc.traefik.io") { 194 | a.href = a.href + window.location.pathname.split('/')[1] + "/"; 195 | } 196 | if (versions[i].path) { 197 | a.href = a.href + versions[i].path + "/"; 198 | } 199 | a.title = versions[i].text; 200 | a.text = versions[i].text; 201 | 202 | li.appendChild(a); 203 | } 204 | 205 | elt.appendChild(rootLi); 206 | } 207 | 208 | // United theme 209 | 210 | function addMenu(elt, versions){ 211 | const li = document.createElement('li'); 212 | li.classList.add('md-nav__item'); 213 | li.style.cssText = 'padding-top: 1em;'; 214 | 215 | const select = document.createElement('select'); 216 | select.classList.add('md-nav__link'); 217 | select.style.cssText = 'background: white;border: none;color: #00BCD4;-webkit-border-radius: 5px;-moz-border-radius: 5px;border-radius: 5px;overflow: hidden;padding: 0.1em;' 218 | select.setAttribute('onchange', 'location = this.options[this.selectedIndex].value;'); 219 | 220 | for (let i = 0; i < versions.length; i++) { 221 | let opt = document.createElement('option'); 222 | opt.value = window.location.protocol + "//" + window.location.host + "/"; 223 | if (window.location.host === "doc.traefik.io") { 224 | opt.value = opt.value + window.location.pathname.split('/')[1] + "/" 225 | } 226 | if (versions[i].path) { 227 | opt.value = opt.value + versions[i].path + "/" 228 | } 229 | opt.text = versions[i].text; 230 | opt.selected = versions[i].selected; 231 | select.appendChild(opt); 232 | } 233 | 234 | li.appendChild(select); 235 | elt.appendChild(li); 236 | } 237 | 238 | 239 | const unitedSelector = 'div.navbar.navbar-default.navbar-fixed-top div.container div.navbar-collapse.collapse ul.nav.navbar-nav.navbar-right'; 240 | const materialSelector = 'div.md-container main.md-main div.md-main__inner.md-grid div.md-sidebar.md-sidebar--primary div.md-sidebar__scrollwrap div.md-sidebar__inner nav.md-nav.md-nav--primary ul.md-nav__list'; 241 | 242 | let elt = document.querySelector(materialSelector); 243 | if (elt) { 244 | addMaterialMenu(elt, versions); 245 | 246 | {{- range $version := .Versions }} 247 | {{- if and (eq $version.Name $.Current) (eq $version.State "OBSOLETE") }} 248 | addBannerMaterial(versions); 249 | {{- end}} 250 | {{- end}} 251 | } else { 252 | const elt = document.querySelector(unitedSelector); 253 | addMenu(elt, versions); 254 | 255 | {{- range $version := .Versions }} 256 | {{- if and (eq $version.Name $.Current) (eq $version.State "OBSOLETE") }} 257 | addBannerUnited(versions); 258 | {{- end}} 259 | {{- end}} 260 | } 261 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // NoOption empty struct. 4 | type NoOption struct{} 5 | 6 | // Configuration task configuration. 7 | type Configuration struct { 8 | Owner string `short:"o" description:"Repository owner. [required]"` 9 | RepositoryName string `short:"r" long:"repo-name" description:"Repository name. [required]"` 10 | Debug bool `long:"debug" description:"Debug mode."` 11 | DockerfileURL string `short:"d" long:"dockerfile-url" description:"Use this Dockerfile when --dockerfile-name is not found. Can be a file path. [required]"` 12 | DockerfileName string `long:"dockerfile-name" description:"Search and use this Dockerfile in the repository (in './docs/' or in './') for building documentation."` 13 | ExperimentalBranchName string `long:"exp-branch" description:"Build a branch as experimental."` 14 | ExcludedBranches []string `long:"exclude" description:"Exclude branches from the documentation generation."` 15 | DockerImageName string `long:"image-name" description:"Docker image name."` 16 | Menu *MenuFiles `long:"menu" description:"Menu templates files."` 17 | RequirementsURL string `long:"rqts-url" description:"Use this requirements.txt to merge with the current requirements.txt. Can be a file path."` 18 | NoCache bool `long:"no-cache" description:"Set to 'true' to disable the Docker build cache."` 19 | ForceEditionURI bool `long:"force-edit-url" description:"Add a dedicated edition URL for each version."` 20 | } 21 | 22 | // MenuFiles menu template files references. 23 | type MenuFiles struct { 24 | JsURL string `long:"js-url" description:"URL of the template of the JS file use for the multi version menu."` 25 | JsFile string `long:"js-file" description:"File path of the template of the JS file use for the multi version menu."` 26 | CSSURL string `long:"css-url" description:"URL of the template of the CSS file use for the multi version menu."` 27 | CSSFile string `long:"css-file" description:"File path of the template of the CSS file use for the multi version menu."` 28 | } 29 | 30 | // HasJsFile has JS file. 31 | func (m *MenuFiles) HasJsFile() bool { 32 | return m != nil && len(m.JsFile) > 0 || len(m.JsURL) > 0 33 | } 34 | 35 | // HasCSSFile has CSS file. 36 | func (m *MenuFiles) HasCSSFile() bool { 37 | return m != nil && len(m.CSSFile) > 0 || len(m.CSSURL) > 0 38 | } 39 | 40 | // VersionsInformation versions information. 41 | type VersionsInformation struct { 42 | Current string 43 | Latest string 44 | Experimental string 45 | CurrentPath string 46 | } 47 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | var ( 9 | version = "dev" 10 | commit = "I don't remember exactly" 11 | date = "I don't remember exactly" 12 | ) 13 | 14 | // displayVersion DisplayVersion version. 15 | func displayVersion(appName string) { 16 | fmt.Printf(`%s: 17 | version : %s 18 | commit : %s 19 | build date : %s 20 | go version : %s 21 | go compiler : %s 22 | platform : %s/%s 23 | `, appName, version, commit, date, runtime.Version(), runtime.Compiler, runtime.GOOS, runtime.GOARCH) 24 | } 25 | --------------------------------------------------------------------------------