├── .gitignore ├── cmd ├── testdata │ ├── cfg.yml │ ├── tmpl.tmpl │ ├── values.yml │ └── template.tmpl ├── mdtmpl_test.go └── mdtmpl.go ├── pkg ├── template │ ├── testdata │ │ ├── include.md │ │ ├── tmpl.tmpl │ │ ├── values.yml │ │ ├── tmpl-vars.tmpl │ │ └── toc.md │ ├── template_test.go │ └── template.go └── commit │ ├── commit.go │ └── commit_test.go ├── README.md.tmpl ├── Dockerfile.goreleaser ├── .pre-commit-hooks.yaml ├── requirements.txt ├── main.go ├── docs ├── tips.md ├── pre-commit-hook.md ├── installation.md ├── usage.md ├── index.md └── templating.md ├── README.md ├── .pre-commit-config.yaml ├── Makefile ├── .golang-ci.yml ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── test.yml │ ├── mkdocs.yml │ └── release.yml ├── go.mod ├── mkdocs.yml ├── .goreleaser.yml └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | mdtmpl 2 | coverage.out 3 | -------------------------------------------------------------------------------- /cmd/testdata/cfg.yml: -------------------------------------------------------------------------------- 1 | settings: 2 | cfg: true 3 | -------------------------------------------------------------------------------- /pkg/template/testdata/include.md: -------------------------------------------------------------------------------- 1 | include this text 2 | -------------------------------------------------------------------------------- /cmd/testdata/tmpl.tmpl: -------------------------------------------------------------------------------- 1 | This is a test {{ exec "echo template" }} 2 | -------------------------------------------------------------------------------- /cmd/testdata/values.yml: -------------------------------------------------------------------------------- 1 | username: admin 2 | password: password 3 | -------------------------------------------------------------------------------- /pkg/template/testdata/tmpl.tmpl: -------------------------------------------------------------------------------- 1 | {{ "hello!" | toUpper | repeat 5 }} 2 | -------------------------------------------------------------------------------- /pkg/template/testdata/values.yml: -------------------------------------------------------------------------------- 1 | username: admin 2 | password: password 3 | -------------------------------------------------------------------------------- /cmd/testdata/template.tmpl: -------------------------------------------------------------------------------- 1 | username={{ .username }} 2 | password={{ .password }} 3 | -------------------------------------------------------------------------------- /pkg/template/testdata/tmpl-vars.tmpl: -------------------------------------------------------------------------------- 1 | username={{ .username }} 2 | password={{ .password }} 3 | -------------------------------------------------------------------------------- /README.md.tmpl: -------------------------------------------------------------------------------- 1 | # ToC 2 | 3 | 4 | 5 | # test 6 | ## test ok 7 | ### hallo 8 | -------------------------------------------------------------------------------- /pkg/template/testdata/toc.md: -------------------------------------------------------------------------------- 1 | # ToC 2 | 3 | # 1. Heading 4 | ## 2. Heading 5 | ### 3. Heading 6 | ## 5. Heading 7 | -------------------------------------------------------------------------------- /Dockerfile.goreleaser: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21.2 2 | 3 | # binary is built by goreleaser and just copied to the image 4 | COPY mdtmpl /usr/bin/mdtmpl 5 | 6 | ENTRYPOINT ["/usr/bin/mdtmpl"] 7 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: mdtmpl 2 | name: Template Markdown files 3 | description: Template Markdown files using mdtmpl 4 | entry: mdtmpl 5 | language: golang 6 | files: (\.tmpl)$ 7 | pass_filenames: false 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | markdown 2 | mkdocs 3 | mkdocs-material 4 | mkdocs-macros-plugin 5 | mkdocs_puml 6 | mkdocs-include-dir-to-nav 7 | mkdocs-with-pdf 8 | mkdocs-git-revision-date-localized-plugin 9 | mkdocs-git-authors-plugin 10 | requests 11 | weasyprint 12 | markdown-include 13 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/FalcoSuessgott/mdtmpl/cmd" 8 | ) 9 | 10 | var version string 11 | 12 | func main() { 13 | cmd.Version = version 14 | 15 | if err := cmd.Execute(); err != nil { 16 | fmt.Fprintf(os.Stderr, "%v.\n", err) 17 | 18 | os.Exit(1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/tips.md: -------------------------------------------------------------------------------- 1 | # Tips & Tricks 2 | 3 | ## Workflow 4 | Personally, I use [`entr`](https://github.com/eradman/entr) to automatically run `mdtmpl` every time my template file (e.g `README.md.tmpl`) changes. Simply output the **filenames** you want to watch on `STDOUT` and pipe them into `entr` with the command to be executed at file change: 5 | 6 | ```bash 7 | > echo README.md.tmpl | entr mdtmpl -f 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/pre-commit-hook.md: -------------------------------------------------------------------------------- 1 | # pre-commit hook 2 | Add the following config to your `.pre-commit-config.yaml` file and adjust the `args` to your needs. 3 | Mae sure to run `pre-commit install` and `pre-commit autoupdate` to stick to the latest version: 4 | ```yaml 5 | repos: 6 | - repo: https://github.com/FalcoSuessgott/mdtmpl 7 | rev: v0.0.6 8 | hooks: 9 | - id: mdtmpl 10 | args: [-t=README.md.tmpl, -f, -o=README.md] 11 | ``` 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mdtmpl 2 | Tired of copy-pasting your example configurations and command outputs into your README? 3 | 4 | `mdtmpl` is a dead-simple little CLI tool that runs instructions defined in [Markdown comments](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#hiding-content-with-comments), such as ``. 5 | 6 | **Check out the [docs](https://falcosuessgott.github.io/mdtmpl/)** 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-case-conflict 9 | - id: check-symlinks 10 | - id: check-json 11 | - id: mixed-line-ending 12 | args: ["--fix=lf"] 13 | - id: no-commit-to-branch 14 | args: [--branch, main] 15 | - id: pretty-format-json 16 | args: [--autofix, --no-sort-keys] 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: help 2 | 3 | .PHONY: help 4 | help: ## list makefile targets 5 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 6 | 7 | PHONY: fmt 8 | fmt: ## format go files 9 | gofumpt -w . 10 | gci write . 11 | 12 | PHONY: test 13 | test: ## display test coverage 14 | gotestsum -- -v -race -coverprofile="coverage.out" -covermode=atomic ./... 15 | 16 | .PHONY: lint 17 | lint: ## lint go files 18 | golangci-lint run -c .golang-ci.yml 19 | -------------------------------------------------------------------------------- /.golang-ci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | lll: 3 | line-length: 180 4 | linters: 5 | enable-all: true 6 | disable: 7 | - testpackage 8 | - forbidigo 9 | - paralleltest 10 | - varnamelen 11 | - rowserrcheck 12 | - sqlclosecheck 13 | - wastedassign 14 | - exhaustruct 15 | - nolintlint 16 | - wrapcheck 17 | - depguard 18 | - err113 19 | - intrange 20 | - copyloopvar 21 | - gochecknoglobals 22 | - revive 23 | - godox 24 | - tagalign 25 | - tagliatelle 26 | - gomoddirectives 27 | - dupword 28 | - exportloopref 29 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | Find all `mdtmpl` releases [here](https://github.com/FalcoSuessgott/mdtmpl/releases) or simply download the latest by running: 3 | 4 | ## curl 5 | ```bash 6 | version=$(curl https://api.github.com/repos/falcosuessgott/mdtmpl/releases/latest -s | jq .name -r) 7 | curl -OL "https://github.com/FalcoSuessgott/mdtmpl/releases/download/${version}/mdtmpl_$(uname)_$(uname -m).tar.gz" 8 | tar xzf mdtmpl_$(uname)_$(uname -m).tar.gz 9 | chmod u+x mdtmpl 10 | ./mdtmpl version 11 | ``` 12 | 13 | ## brew 14 | ```bash 15 | brew install falcosuessgott/tap/mdtmpl 16 | ``` 17 | 18 | ## docker 19 | ```bash 20 | docker run falcosuessgott/mdtmpl 21 | ``` 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "08:00" 8 | labels: 9 | - "dependencies" 10 | commit-message: 11 | prefix: "feat" 12 | include: "scope" 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | time: "08:00" 18 | labels: 19 | - "dependencies" 20 | commit-message: 21 | prefix: "chore" 22 | include: "scope" 23 | - package-ecosystem: "docker" 24 | directory: "/" 25 | schedule: 26 | interval: "daily" 27 | time: "08:00" 28 | labels: 29 | - "dependencies" 30 | commit-message: 31 | prefix: "feat" 32 | include: "scope" 33 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | permissions: 9 | # Required: allow read access to the content for analysis. 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | pull-requests: read 13 | # Optional: Allow write access to checks to allow the action to annotate code in the PR. 14 | checks: write 15 | 16 | jobs: 17 | golangci: 18 | name: lint 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - uses: actions/setup-go@v5 24 | with: 25 | go-version: '1.23.3' 26 | cache: false 27 | 28 | - name: golangci-lint 29 | uses: golangci/golangci-lint-action@v6 30 | with: 31 | version: v1.63.4 32 | args: -c .golang-ci.yml -v --timeout=5m 33 | env: 34 | GO111MODULES: off 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test and coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: '1.23.3' 19 | cache: false 20 | 21 | - name: go get 22 | run: go get ./... 23 | 24 | - name: Run coverage 25 | run: | 26 | go test -v -race -coverprofile="coverage.out" -covermode=atomic ./... 27 | env: 28 | VAULT_VERSION: latest 29 | # https://github.com/testcontainers/testcontainers-go/issues/1782 30 | TESTCONTAINERS_RYUK_DISABLED: true 31 | 32 | - name: Upload coverage reports to Codecov 33 | uses: codecov/codecov-action@v5.1.2 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | slug: FalcoSuessgott/vkv 37 | -------------------------------------------------------------------------------- /.github/workflows/mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: mkdocs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Configure Git Credentials 21 | run: | 22 | git config user.name github-actions[bot] 23 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: 3.x 28 | 29 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 30 | 31 | - uses: actions/cache@v4 32 | with: 33 | key: mkdocs-material-${{ env.cache_id }} 34 | path: .cache 35 | restore-keys: | 36 | mkdocs-material- 37 | 38 | - run: pip install mkdocs-material 39 | - run: pip install -r requirements.txt 40 | 41 | - run: mkdocs gh-deploy --force 42 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | Per default, `mdtmpl` uses `README.md.tmpl` as the template file (change with `-t`) and attempts to write the rendered output to `README.md` (change with `-o`). If a `README.md` already exists, you will have to specify `--force` to overwrite its content. You can enable dry-runs using `-d`. 3 | 4 | ## CLI Args & Environment Vars 5 | 6 | ```bash 7 | $> mdtmpl -h 8 | template Markdown files using Go templates and Markdown comments 9 | 10 | Usage: 11 | mdtmpl [flags] 12 | 13 | Flags: 14 | -d, --dry-run dry run, print output to stdout (env: MDTMPL_DRY_RUN) 15 | -f, --force overwrite output file (env: MDTMPL_FORCE) 16 | -h, --help help for mdtmpl 17 | -i, --init Initialize a starting README.md.tmpl (env: MDTMPL_INIT) 18 | -o, --output string path to the output file (env: MDTMPL_OUTPUT_FILE) (default "README.md") 19 | -t, --template string path to a mdtmpl template file (env: MDTMPL_TEMPLATE_FILE) (default "README.md.tmpl") 20 | --version print version (env: MDTMPL_VERSION) 21 | ``` 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/FalcoSuessgott/mdtmpl 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/Masterminds/semver/v3 v3.3.1 7 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d 8 | github.com/caarlos0/env/v11 v11.3.1 9 | github.com/go-sprout/sprout v1.0.0-rc.3 10 | github.com/leodido/go-conventionalcommits v0.12.0 11 | github.com/spf13/cobra v1.8.1 12 | github.com/stretchr/testify v1.10.0 13 | ) 14 | 15 | require ( 16 | dario.cat/mergo v1.0.1 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/google/go-cmp v0.6.0 // indirect 19 | github.com/google/uuid v1.6.0 // indirect 20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 21 | github.com/mitchellh/copystructure v1.2.0 // indirect 22 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 23 | github.com/pmezard/go-difflib v1.0.0 // indirect 24 | github.com/sirupsen/logrus v1.9.3 // indirect 25 | github.com/spf13/cast v1.7.0 // indirect 26 | github.com/spf13/pflag v1.0.5 // indirect 27 | golang.org/x/sys v0.28.0 // indirect 28 | golang.org/x/text v0.21.0 // indirect 29 | gopkg.in/yaml.v3 v3.0.1 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /pkg/commit/commit.go: -------------------------------------------------------------------------------- 1 | package commit 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/Masterminds/semver/v3" 7 | "github.com/leodido/go-conventionalcommits" 8 | "github.com/leodido/go-conventionalcommits/parser" 9 | ) 10 | 11 | type SemVerFunc func(*semver.Version) string 12 | 13 | var ( 14 | IncMajor SemVerFunc = func(v *semver.Version) string { return v.IncMajor().String() } 15 | IncMinor SemVerFunc = func(v *semver.Version) string { return v.IncMinor().String() } 16 | IncPatch SemVerFunc = func(v *semver.Version) string { return v.IncPatch().String() } 17 | ) 18 | 19 | func ParseConventionalCommit(commit []byte) (SemVerFunc, error) { 20 | cc, err := parser.NewMachine( 21 | conventionalcommits.WithTypes(conventionalcommits.TypesConventional), 22 | conventionalcommits.WithBestEffort()).Parse(commit) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | if cc.IsBreakingChange() { 28 | return IncMajor, nil 29 | } 30 | 31 | if cc.IsFeat() { 32 | return IncMinor, nil 33 | } 34 | 35 | if cc.IsFix() { 36 | return IncPatch, nil 37 | } 38 | 39 | return nil, errors.New("commit is not a conventional commit") 40 | } 41 | -------------------------------------------------------------------------------- /pkg/commit/commit_test.go: -------------------------------------------------------------------------------- 1 | package commit 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Masterminds/semver/v3" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestParseConventionalCommit(t *testing.T) { 11 | testCases := []struct { 12 | name string 13 | commit string 14 | v string 15 | expV string 16 | err bool 17 | }{ 18 | { 19 | name: "minor", 20 | commit: "feat: add new feature", 21 | v: "1.2.3", 22 | expV: "1.3.0", 23 | }, 24 | { 25 | name: "patch", 26 | commit: "fix: add new feature", 27 | v: "1.2.3", 28 | expV: "1.2.4", 29 | }, 30 | { 31 | name: "breaking", 32 | commit: "chore!: add new feature", 33 | v: "1.2.3", 34 | expV: "2.0.0", 35 | }, 36 | // { 37 | // name: "breaking", 38 | // commit: "chore!: add new feature", 39 | // v: "v1.2.3", 40 | // expV: "v2.0.0", 41 | // }, 42 | } 43 | 44 | for _, tc := range testCases { 45 | t.Run(tc.name, func(t *testing.T) { 46 | res, err := ParseConventionalCommit([]byte(tc.commit)) 47 | 48 | if tc.err { 49 | require.Error(t, err, tc.name) 50 | 51 | return 52 | } 53 | 54 | require.NoError(t, err, tc.name) 55 | 56 | v, err := semver.NewVersion(tc.v) 57 | require.NoError(t, err, tc.name) 58 | require.Equal(t, tc.expV, res(v), tc.name) 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | pull_request: 8 | 9 | permissions: 10 | contents: write 11 | packages: write 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | env: 17 | DOCKER_CLI_EXPERIMENTAL: "enabled" 18 | steps: 19 | - 20 | name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: '1.23.3' 28 | cache: false 29 | 30 | - 31 | name: Set up QEMU 32 | uses: docker/setup-qemu-action@v3 33 | - 34 | name: Set up Docker Buildx 35 | uses: docker/setup-buildx-action@v3 36 | 37 | - 38 | name: docker hub login 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKERHUB_USERNAME }} 42 | password: ${{ secrets.DOCKERHUB_TOKEN }} 43 | 44 | # if tag release 45 | - 46 | name: Run GoReleaser 47 | uses: goreleaser/goreleaser-action@v6 48 | if: startsWith(github.ref, 'refs/tags/v') 49 | with: 50 | version: latest 51 | args: release --clean 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | HOMEBREW_TAP: ${{ secrets.HOMEBREW_TAP }} 55 | # if no tag test release build 56 | - 57 | name: Run GoReleaser skip publishing 58 | uses: goreleaser/goreleaser-action@v6 59 | if: "!startsWith(github.ref, 'refs/tags/v')" 60 | with: 61 | version: latest 62 | args: release --skip=publish --skip=validate 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | HOMEBREW_TAP: ${{ secrets.HOMEBREW_TAP }} 66 | -------------------------------------------------------------------------------- /cmd/mdtmpl_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | //nolint:funlen 11 | func TestParseConfig(t *testing.T) { 12 | testCases := []struct { 13 | name string 14 | tmpl string 15 | exp string 16 | err bool 17 | }{ 18 | { 19 | name: "simple", 20 | tmpl: ``, 21 | exp: ` 22 | HELLO!HELLO!HELLO!HELLO!HELLO! 23 | `, 24 | }, 25 | { 26 | name: "exec", 27 | tmpl: ``, 28 | exp: ` 29 | hallo 30 | hallo 31 | hallo 32 | `, 33 | }, 34 | { 35 | name: "fle", 36 | tmpl: ``, 37 | exp: `` + "\n```yml" + ` 38 | settings: 39 | cfg: true 40 | 41 | ` + "```\n", 42 | }, 43 | { 44 | name: "tmpl", 45 | tmpl: ``, 46 | exp: ` 47 | This is a test template 48 | `, 49 | }, 50 | { 51 | name: "tmplWithVars", 52 | tmpl: ``, 53 | exp: ` 54 | username=admin 55 | password=password 56 | 57 | `, 58 | }, 59 | { 60 | name: "regularComment", 61 | tmpl: ``, 62 | exp: ` 63 | `, 64 | }, 65 | } 66 | 67 | for _, tc := range testCases { 68 | t.Run(tc.name, func(t *testing.T) { 69 | s := strings.NewReader(tc.tmpl) 70 | res, err := parse(s) 71 | 72 | if tc.err { 73 | require.Error(t, err) 74 | 75 | return 76 | } 77 | 78 | require.NoError(t, err) 79 | require.Equal(t, tc.exp, string(res)) 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: mdtmpl 2 | site_description: A dead simple Markdown templating tool 3 | site_author: FalcoSuessgott 4 | 5 | repo_name: FalcoSuessgott/mdtmpl 6 | repo_url: https://github.com/FalcoSuessgott/mdtmpl 7 | 8 | docs_dir: docs/ 9 | 10 | plugins: 11 | - search 12 | - git-authors 13 | - git-revision-date-localized: 14 | locale: en 15 | enable_creation_date: false 16 | 17 | nav: 18 | - index.md 19 | - installation.md 20 | - usage.md 21 | - templating.md 22 | - pre-commit-hook.md 23 | - tips.md 24 | 25 | markdown_extensions: 26 | - pymdownx.superfences: 27 | custom_fences: 28 | - name: mermaid 29 | class: mermaid 30 | - pymdownx.tabbed: 31 | alternate_style: true 32 | - pymdownx.highlight: 33 | anchor_linenums: true 34 | line_spans: __span 35 | pygments_lang_class: true 36 | - pymdownx.snippets 37 | - pymdownx.inlinehilite 38 | - admonition 39 | - def_list 40 | - footnotes 41 | - attr_list 42 | - md_in_html 43 | - tables 44 | - pymdownx.tasklist: 45 | custom_checkbox: true 46 | - footnotes 47 | - pymdownx.tabbed: 48 | alternate_style: true 49 | - toc: 50 | permalink: true 51 | 52 | theme: 53 | icon: 54 | edit: material/pencil 55 | view: material/eye 56 | repo: fontawesome/brands/github 57 | name: material 58 | 59 | favicon: assets/favicon.ico 60 | logo: assets/logo.png 61 | language: en 62 | palette: 63 | # Palette toggle for light mode 64 | - scheme: default 65 | primary: blue 66 | accent: indigo 67 | toggle: 68 | icon: material/eye 69 | name: Switch to dark mode 70 | # Palette toggle for dark mode 71 | - scheme: slate 72 | primary: blue 73 | accent: indigo 74 | toggle: 75 | icon: material/eye-outline 76 | name: Switch to light mode 77 | features: 78 | - navigation.tabs 79 | - navigation.tabs.sticky 80 | - navigation.sections 81 | - navigation.indexes 82 | - content.code.copy 83 | - content.action.edit 84 | - navigation.top 85 | - navigation.expand 86 | - navigation.footer 87 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # mdtmpl 2 | GitHub Downloads (all assets, all releases) 3 | Docker Pulls 4 | 5 | 6 | Tired of copy-pasting your example configurations and command outputs into your README? 7 | 8 | `mdtmpl` is a dead-simple little CLI tool that runs instructions defined in [Markdown comments](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#hiding-content-with-comments), such as ``. 9 | 10 | ## Example 11 | Imagine the following `README.md.tmpl`, when invoked, `mdtmpl` will interpret and render the instructions defined within `` to the following: 12 | 13 | === "`README.md.tmpl`" 14 | 15 | ```md 16 | ### Example Configuration 17 | Here are all available configuration options: 18 | 19 | 20 | ### List Docker Containers 21 | You should now see docker containers running: 22 | 23 | ``` 24 | 25 | === "`README.md`" 26 | ### Example Configuration 27 | Here are all available configuration options: 28 | 29 | ```yaml 30 | auth: 31 | basic: true 32 | ``` 33 | 34 | ### List Docker Containers 35 | You should now see docker containers running: 36 | 37 | ```bash 38 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 39 | cf4f9cec8faa registry:2 "/entrypoint.sh /etc…" 7 weeks ago Up 10 seconds 0.0.0.0:5000->5000/tcp, :::5000->5000/tcp registry 40 | 006560ea14d9 hello-world "/hello" 7 weeks ago Exited (0) 7 weeks ago dreamy_feistel 41 | d9d050df8a0f hello-world "/hello" 7 weeks ago Exited (0) 7 weeks ago 42 | ``` 43 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | env: 2 | - CGO_ENABLED=0 3 | 4 | builds: 5 | - 6 | binary: mdtmpl 7 | ldflags: -s -w -X main.version={{ .Version }} 8 | goos: 9 | - linux 10 | - darwin 11 | - windows 12 | goarch: 13 | - amd64 14 | - arm64 15 | 16 | archives: 17 | - 18 | builds: 19 | - mdtmpl 20 | format_overrides: 21 | - goos: windows 22 | format: zip 23 | name_template: >- 24 | {{- .ProjectName }}_ 25 | {{- title .Os }}_ 26 | {{- if eq .Arch "amd64" }}x86_64 27 | {{- else if eq .Arch "386" }}i386 28 | {{- else }}{{ .Arch }}{{ end }} 29 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 30 | 31 | checksum: 32 | name_template: "checksums.txt" 33 | 34 | changelog: 35 | sort: asc 36 | use: github 37 | filters: 38 | exclude: 39 | - '^test:' 40 | - '^chore' 41 | - 'merge conflict' 42 | - Merge pull request 43 | - Merge remote-tracking branch 44 | - Merge branch 45 | - go mod tidy 46 | groups: 47 | - title: Dependency updates 48 | regexp: '^.*?(feat|fix)\(deps\)!?:.+$' 49 | order: 300 50 | - title: 'New Features' 51 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 52 | order: 100 53 | - title: 'Bug fixes' 54 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' 55 | order: 200 56 | - title: 'Documentation updates' 57 | regexp: ^.*?doc(\([[:word:]]+\))??!?:.+$ 58 | order: 400 59 | - title: Other work 60 | order: 9999 61 | 62 | brews: 63 | - name: mdtmpl 64 | repository: 65 | owner: FalcoSuessgott 66 | name: homebrew-tap 67 | branch: main 68 | token: "{{ .Env.HOMEBREW_TAP }}" 69 | directory: Formula 70 | homepage: https://github.com/FalcoSuessgott/mdtmpl 71 | description: "mdtpl" 72 | install: | 73 | bin.install "mdtmpl" 74 | test: | 75 | system "#{bin}/mdtmpl" 76 | 77 | dockers: 78 | - image_templates: 79 | - 'falcosuessgott/{{.ProjectName}}:{{ .Tag }}-amd64' 80 | dockerfile: Dockerfile.goreleaser 81 | use: buildx 82 | build_flag_templates: 83 | - "--pull" 84 | - "--label=io.artifacthub.package.readme-url=https://raw.githubusercontent.com/FalcoSuessgott/mdtmpl/refs/heads/main/README.md" 85 | - "--label=io.artifacthub.package.maintainers=[{\"name\":\"Tom Morelly\",\"email\":\"tommorelly@gmail.com\"}]" 86 | - "--label=io.artifacthub.package.license=MIT" 87 | - "--label=org.opencontainers.image.description=A dead simple Markdown templating tool" 88 | - "--label=org.opencontainers.image.created={{.Date}}" 89 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 90 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 91 | - "--label=org.opencontainers.image.version={{.Version}}" 92 | - "--label=org.opencontainers.image.source={{.GitURL}}" 93 | - "--platform=linux/amd64" 94 | - image_templates: 95 | - 'falcosuessgott/{{.ProjectName}}:{{ .Tag }}-arm64' 96 | dockerfile: Dockerfile.goreleaser 97 | use: buildx 98 | build_flag_templates: 99 | - "--pull" 100 | - "--label=io.artifacthub.package.readme-url=https://raw.githubusercontent.com/FalcoSuessgott/mdtmpl/refs/heads/main/README.md" 101 | - "--label=io.artifacthub.package.maintainers=[{\"name\":\"Tom Morelly\",\"email\":\"tommorelly@gmail.com\"}]" 102 | - "--label=io.artifacthub.package.license=MIT" 103 | - "--label=org.opencontainers.image.description=A dead simple Markdown templating tool" 104 | - "--label=org.opencontainers.image.created={{.Date}}" 105 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 106 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 107 | - "--label=org.opencontainers.image.version={{.Version}}" 108 | - "--label=org.opencontainers.image.source={{.GitURL}}" 109 | - "--platform=linux/arm64" 110 | goarch: arm64 111 | 112 | docker_manifests: 113 | - name_template: 'falcosuessgott/{{.ProjectName}}:{{ .Tag }}' 114 | image_templates: 115 | - 'falcosuessgott/{{.ProjectName}}:{{ .Tag }}-amd64' 116 | - 'falcosuessgott/{{.ProjectName}}:{{ .Tag }}-arm64' 117 | - name_template: 'falcosuessgott/{{.ProjectName}}:latest' 118 | image_templates: 119 | - 'falcosuessgott/{{.ProjectName}}:{{ .Tag }}-amd64' 120 | - 'falcosuessgott/{{.ProjectName}}:{{ .Tag }}-arm64' 121 | -------------------------------------------------------------------------------- /cmd/mdtmpl.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "regexp" 11 | 12 | "github.com/FalcoSuessgott/mdtmpl/pkg/template" 13 | "github.com/caarlos0/env/v11" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | const ( 18 | envVarPrefix = "MDTMPL_" 19 | defaultTemplateFile = "README.md.tmpl" 20 | defaultOutputFile = "README.md" 21 | ) 22 | 23 | const commentRegex = `` 24 | 25 | var Version string 26 | 27 | type Options struct { 28 | TemplateFile string `env:"TEMPLATE_FILE"` 29 | OutputFile string `env:"OUTPUT_FILE"` 30 | DryRun bool `env:"DRY_RUN"` 31 | Force bool `env:"FORCE"` 32 | Init bool `env:"INIT"` 33 | Version bool `env:"VERSION"` 34 | } 35 | 36 | func defaultOpts() *Options { 37 | return &Options{ 38 | OutputFile: defaultOutputFile, 39 | TemplateFile: defaultTemplateFile, 40 | } 41 | } 42 | 43 | var initTemplate = `# ToC 44 | 45 | ` 46 | 47 | // nolint: cyclop, funlen 48 | func NewRootCmd() *cobra.Command { 49 | o := defaultOpts() 50 | 51 | if err := env.ParseWithOptions(o, env.Options{Prefix: envVarPrefix}); err != nil { 52 | log.Fatalf("cant parse env vars: %v", err) 53 | } 54 | 55 | cmd := &cobra.Command{ 56 | Use: "mdtmpl", 57 | Short: "template Markdown files using Go templates and Markdown comments", 58 | SilenceErrors: true, 59 | SilenceUsage: true, 60 | RunE: func(cmd *cobra.Command, args []string) error { 61 | if o.Version { 62 | fmt.Println(Version) 63 | 64 | return nil 65 | } 66 | 67 | if o.Init { 68 | if _, err := os.Stat(o.TemplateFile); err == nil && !o.Force { 69 | return fmt.Errorf("template file %s already exists. Use --force to overwrite ", o.TemplateFile) 70 | } 71 | 72 | //nolint:gosec, mnd 73 | if err := os.WriteFile(o.TemplateFile, []byte(initTemplate), 0o644); err != nil { 74 | return fmt.Errorf("cannot write to %s: %w", o.TemplateFile, err) 75 | } 76 | } 77 | 78 | f, err := os.Open(o.TemplateFile) 79 | if err != nil { 80 | return fmt.Errorf("cannot open \"%s\": %w", o.TemplateFile, err) 81 | } 82 | 83 | defer func() { 84 | _ = f.Close() 85 | }() 86 | 87 | res, err := parse(f, template.WithTemplateFile(o.TemplateFile)) 88 | if err != nil { 89 | return fmt.Errorf("cannot parse config %s: %w", o.TemplateFile, err) 90 | } 91 | 92 | if o.DryRun { 93 | fmt.Println(string(res)) 94 | 95 | return nil 96 | } 97 | 98 | if _, err := os.Stat(o.OutputFile); err == nil && !o.Force { 99 | return fmt.Errorf("output file %s already exists. Use -f to overwrite", o.OutputFile) 100 | } 101 | 102 | //nolint:gosec, mnd 103 | if err := os.WriteFile(o.OutputFile, res, 0o644); err != nil { 104 | return fmt.Errorf("cannot write to %s: %w", o.OutputFile, err) 105 | } 106 | 107 | return nil 108 | }, 109 | } 110 | 111 | cmd.Flags().StringVarP(&o.TemplateFile, "template", "t", o.TemplateFile, "path to a mdtmpl template file (env: MDTMPL_TEMPLATE_FILE)") 112 | cmd.Flags().StringVarP(&o.OutputFile, "output", "o", o.OutputFile, "path to the output file (env: MDTMPL_OUTPUT_FILE)") 113 | cmd.Flags().BoolVarP(&o.Force, "force", "f", o.Force, "overwrite output file (env: MDTMPL_FORCE)") 114 | cmd.Flags().BoolVarP(&o.DryRun, "dry-run", "d", o.DryRun, "dry run, print output to stdout (env: MDTMPL_DRY_RUN)") 115 | cmd.Flags().BoolVarP(&o.Init, "init", "i", o.Init, "Initialize a starting README.md.tmpl (env: MDTMPL_INIT)") 116 | 117 | cmd.Flags().BoolVar(&o.Version, "version", o.Version, "print version (env: MDTMPL_VERSION)") 118 | 119 | return cmd 120 | } 121 | 122 | func Execute() error { 123 | if err := NewRootCmd().Execute(); err != nil { 124 | return fmt.Errorf("[ERROR] %w", err) 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func parse(r io.Reader, opts ...template.RendererOptions) ([]byte, error) { 131 | var resultFile bytes.Buffer 132 | 133 | re := regexp.MustCompile(commentRegex) 134 | 135 | ln := 1 136 | 137 | scanner := bufio.NewScanner(r) 138 | for scanner.Scan() { 139 | if re.MatchString(scanner.Text()) { 140 | resultFile.Write(scanner.Bytes()) 141 | resultFile.WriteString("\n") 142 | 143 | b := []byte(regexp.MustCompile(commentRegex).FindStringSubmatch(scanner.Text())[1]) 144 | 145 | if containsActions, err := template.ContainsTemplateActions(b, opts...); err != nil { 146 | return nil, err 147 | } else if !containsActions { 148 | ln++ 149 | 150 | continue 151 | } 152 | 153 | result, err := template.Render(b, nil, opts...) 154 | if err != nil { 155 | return nil, fmt.Errorf("cannot render template at line %d: %w", ln, err) 156 | } 157 | 158 | resultFile.Write(result.Bytes()) 159 | resultFile.WriteString("\n") 160 | } else { 161 | resultFile.Write(scanner.Bytes()) 162 | resultFile.WriteString("\n") 163 | } 164 | 165 | ln++ 166 | } 167 | 168 | return resultFile.Bytes(), nil 169 | } 170 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 4 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 5 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= 6 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 7 | github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= 8 | github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 14 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 15 | github.com/go-sprout/sprout v1.0.0-rc.3 h1:VsVYDglX/U7JqFU21AmAjY0PJ4wtf6/vIuAMjkQoW7I= 16 | github.com/go-sprout/sprout v1.0.0-rc.3/go.mod h1:zDlFf3uJtlr9w0LkhOfuAeaqNzziQ/1XQWoiSm6cxro= 17 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 18 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 20 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 21 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 22 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 23 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 24 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 25 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 26 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 27 | github.com/leodido/go-conventionalcommits v0.12.0 h1:pG01rl8Ze+mxnSSVB2wPdGASXyyU25EGwLUc0bWrmKc= 28 | github.com/leodido/go-conventionalcommits v0.12.0/go.mod h1:DW+n8pQb5w/c7Vba7iGOMS3rkbPqykVlnrDykGjlsJM= 29 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 30 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 31 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 32 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 34 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 35 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 36 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 37 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 38 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 39 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 40 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 41 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 42 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 43 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 44 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 45 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 46 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 47 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 48 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 49 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 50 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 51 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 52 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 54 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 55 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 56 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 57 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 58 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 59 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 60 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 61 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 62 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 63 | -------------------------------------------------------------------------------- /pkg/template/template_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | // nolint: funlen 10 | func TestTemplateFuncMap(t *testing.T) { 11 | testcases := []struct { 12 | name string 13 | opts []RendererOptions 14 | vars interface{} 15 | tmpl string 16 | exp string 17 | err bool 18 | }{ 19 | { 20 | name: "simple render", 21 | tmpl: `{{ .Key }}`, 22 | vars: map[string]interface{}{"Key": "Value"}, 23 | exp: "Value", 24 | }, 25 | { 26 | name: "missing key", 27 | tmpl: `{{ .Key }}`, 28 | err: true, 29 | }, 30 | { 31 | name: "truncate", 32 | tmpl: `{{ "this is a line\n" | truncate }}`, 33 | exp: "this is a line", 34 | }, 35 | { 36 | name: "truncate multiple lines", 37 | tmpl: `{{ "this is a line\n\n\n" | truncate }}`, 38 | exp: "this is a line", 39 | }, 40 | { 41 | name: "truncate multiple lines", 42 | tmpl: `{{ "this is a line\n\n\n" | truncate }}`, 43 | exp: "this is a line", 44 | }, 45 | { 46 | name: "stripansi", 47 | tmpl: `{{ "\x1b[38;5;140mfoo\x1b[0m bar" | stripansi }}`, 48 | exp: "foo bar", 49 | }, 50 | { 51 | name: "exec & code", 52 | tmpl: `{{ exec "echo hallo" | truncate | code "bash" }}`, 53 | exp: "```bash\n" + "hallo\n" + "```", 54 | }, 55 | { 56 | name: "hook", 57 | tmpl: `{{ hook "echo hallo" }}`, 58 | exp: "", 59 | }, 60 | { 61 | name: "toc", 62 | opts: []RendererOptions{WithTemplateFile("testdata/toc.md")}, 63 | tmpl: `# ToC 64 | {{ toc }} 65 | # 1. Heading 66 | ## 2. Heading 67 | ### 3. Heading 68 | ## 5. Heading`, 69 | exp: `# ToC 70 | - [ToC](#toc) 71 | - [1. Heading](#1.-heading) 72 | - [2. Heading](#2.-heading) 73 | - [3. Heading](#3.-heading) 74 | - [5. Heading](#5.-heading) 75 | 76 | # 1. Heading 77 | ## 2. Heading 78 | ### 3. Heading 79 | ## 5. Heading`, 80 | }, 81 | { 82 | name: "file", 83 | tmpl: `This is text 84 | {{ file "testdata/include.md" }}`, 85 | exp: `This is text 86 | include this text 87 | `, 88 | }, 89 | { 90 | name: "tmpl", 91 | tmpl: `This is text 92 | {{ tmpl "testdata/tmpl.tmpl" }}`, 93 | exp: `This is text 94 | HELLO!HELLO!HELLO!HELLO!HELLO! 95 | `, 96 | }, 97 | { 98 | name: "tmpl with vars", 99 | tmpl: `This is text 100 | {{ tmplWithVars "./testdata/tmpl-vars.tmpl" (file "./testdata/values.yml" | fromYAML) }}`, 101 | exp: `This is text 102 | username=admin 103 | password=password 104 | `, 105 | }, 106 | { 107 | name: "collapsile", 108 | tmpl: `{{ collapsile "details" (code "bash" "echo hallo") }}`, 109 | exp: `
110 | details 111 | 112 | ` + "```bash" + ` 113 | echo hallo 114 | ` + "```" + ` 115 | 116 |
`, 117 | }, 118 | } 119 | 120 | for _, tc := range testcases { 121 | t.Run(tc.name, func(t *testing.T) { 122 | out, err := Render([]byte(tc.tmpl), tc.vars, tc.opts...) 123 | 124 | if tc.err { 125 | require.Error(t, err, "expected an error but did not get one") 126 | 127 | return 128 | } 129 | 130 | require.NoError(t, err, "expected no error but got one") 131 | require.Equal(t, tc.exp, out.String()) 132 | }) 133 | } 134 | } 135 | 136 | // nolint: funlen 137 | func TestTemplateStatements(t *testing.T) { 138 | testcases := []struct { 139 | name string 140 | opts []RendererOptions 141 | tmpl string 142 | exp bool 143 | err bool 144 | }{ 145 | { 146 | name: "simple render", 147 | tmpl: `{{ .Key }}`, 148 | exp: true, 149 | }, 150 | { 151 | name: "truncate", 152 | tmpl: `{{ "this is a line\n" | truncate }}`, 153 | exp: true, 154 | }, 155 | { 156 | name: "truncate multiple lines", 157 | tmpl: `{{ "this is a line\n\n\n" | truncate }}`, 158 | exp: true, 159 | }, 160 | { 161 | name: "truncate multiple lines", 162 | tmpl: `{{ "this is a line\n\n\n" | truncate }}`, 163 | exp: true, 164 | }, 165 | { 166 | name: "stripansi", 167 | tmpl: `{{ "\x1b[38;5;140mfoo\x1b[0m bar" | stripansi }}`, 168 | exp: true, 169 | }, 170 | { 171 | name: "exec & code", 172 | tmpl: `{{ exec "echo hallo" | truncate | code "bash" }}`, 173 | exp: true, 174 | }, 175 | { 176 | name: "hook", 177 | tmpl: `{{ hook "echo hallo" }}`, 178 | exp: true, 179 | }, 180 | { 181 | name: "toc", 182 | opts: []RendererOptions{WithTemplateFile("testdata/toc.md")}, 183 | tmpl: `# ToC 184 | {{ toc }} 185 | # 1. Heading 186 | ## 2. Heading 187 | ### 3. Heading 188 | ## 5. Heading`, 189 | exp: true, 190 | }, 191 | { 192 | name: "file", 193 | tmpl: `This is text 194 | {{ file "testdata/include.md" }}`, 195 | exp: true, 196 | }, 197 | { 198 | name: "tmpl", 199 | tmpl: `This is text 200 | {{ tmpl "testdata/tmpl.tmpl" }}`, 201 | exp: true, 202 | }, 203 | { 204 | name: "tmpl with vars", 205 | tmpl: `This is text 206 | {{ tmplWithVars "./testdata/tmpl-vars.tmpl" (file "./testdata/values.yml" | fromYAML) }}`, 207 | exp: true, 208 | }, 209 | { 210 | name: "collapsile", 211 | tmpl: `{{ collapsile "details" (code "bash" "echo hallo") }}`, 212 | exp: true, 213 | }, 214 | { 215 | name: "plain text", 216 | tmpl: "example text", 217 | exp: false, 218 | }, 219 | { 220 | name: "markdown comment", 221 | tmpl: "", 222 | exp: false, 223 | }, 224 | } 225 | 226 | for _, tc := range testcases { 227 | t.Run(tc.name, func(t *testing.T) { 228 | out, err := ContainsTemplateActions([]byte(tc.tmpl), tc.opts...) 229 | 230 | if tc.err { 231 | require.Error(t, err, "expected an error but did not get one") 232 | 233 | return 234 | } 235 | 236 | require.NoError(t, err, "expected no error but got one") 237 | require.Equal(t, tc.exp, out) 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /docs/templating.md: -------------------------------------------------------------------------------- 1 | # Templating 2 | A basic `mdtmpl` instruction looks like this: 3 | 4 | ```yaml 5 | 6 | ``` 7 | 8 | `mdtmpl` parses the template file and all its markdown comments and renders its instructions. It uses the [Go`s Template Engine](https://pkg.go.dev/text/template). 9 | 10 | Follow this document to see which template functions are supported. 11 | 12 | ## Piping 13 | You can [pipe the output of one instruction to the next template function as its **last argument**](https://pkg.go.dev/text/template#hdr-Pipelines): 14 | 15 | ```yaml 16 | 17 | ``` 18 | 19 | For example: `` will result in: 20 | `HELLO!HELLO!HELLO!HELLO!HELLO!`. 21 | 22 | ## Template Functions 23 | `mdtmpl` includes all [`sprout`](https://docs.atom.codes/sprout/registries/list-of-all-registries) and [Go`s predefined template functions](https://pkg.go.dev/text/template#hdr-Functions). 24 | 25 | Furthermore, the following functions are also available: 26 | 27 | ### `code "" ""` 28 | > Syntax highlight a given content in a [specified language](https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml) within a code block. 29 | 30 | === "`README.md.tmpl`" 31 | ```c 32 | 33 | ``` 34 | 35 | === "`README.md`" 36 | 37 | ```bash 38 | this is a command 39 | ``` 40 | 41 | ### `exec ""` 42 | > Executes a given command and returns the output and an error (if any) 43 | 44 | !!! tip 45 | `truncate` removes any trailing empty lines. Useful after `exec` 46 | 47 | === "`README.md.tmpl`" 48 | ```yaml 49 | 50 | ``` 51 | 52 | === "`README.md`" 53 | 54 | ```bash 55 | hello world 56 | ``` 57 | 58 | ### `hook ""` 59 | > Executes a given command and returns an error (if any) 60 | 61 | !!! tip 62 | `hook` is useful for setting things up or commands that produce some resources, such as images that you want to include. 63 | 64 | === "`README.md.tmpl`" 65 | ```yaml 66 | 67 | ``` 68 | 69 | === "`README.md`" 70 | 71 | ### `file ""` 72 | > Includes the content of the given file 73 | 74 | ```yaml 75 | # settings.yml 76 | settings: 77 | basic_auth: false 78 | ``` 79 | 80 | === "`README.md.tmpl`" 81 | ```c 82 | 83 | ``` 84 | 85 | === "`README.md`" 86 | 87 | ```yaml 88 | settings: 89 | basic_auth: false 90 | ``` 91 | 92 | ### `fileHTTP ""` 93 | > Includes the content of the given url 94 | 95 | ```yaml 96 | # settings.yml 97 | settings: 98 | basic_auth: false 99 | ``` 100 | 101 | === "`README.md.tmpl`" 102 | ```c 103 | 104 | ``` 105 | 106 | === "`README.md`" 107 | 108 | ```yaml 109 | settings: 110 | basic_auth: false 111 | ``` 112 | 113 | ### `filesInDir "" "` 114 | > Returns the paths of all matching files in the specified directory 115 | 116 | === "`README.md.tmpl`" 117 | ```c 118 | 119 | ``` 120 | 121 | === "`README.md`" 122 | 123 | [.github/dependabot.yml .github/workflows/lint.yml .github/workflows/mkdocs.yml .github/workflows/release.yml .github/workflows/test.yml .golang-ci.yml .goreleaser.yml cmd/testdata/cfg.yml mkdocs.yml pkg/template/testdata/values.yml] 124 | 125 | ### `tmpl ""` 126 | > Includes the rendered content of the given template 127 | 128 | ```yaml 129 | # docs/template.tmpl 130 | This is a test {{ exec "echo template" }} 131 | ``` 132 | 133 | === "`README.md.tmpl`" 134 | ```c 135 | 136 | ``` 137 | 138 | === "`README.md`" 139 | 140 | This is a test template 141 | 142 | ### `tmplWithVars "" ` 143 | > Renders a given template with the specified template values 144 | 145 | ```yaml 146 | # values.yml 147 | name: kubernetes 148 | version: v1.0.0 149 | ``` 150 | 151 | ```yaml 152 | # docs/template.tmpl 153 | This is another template {{ .name }}-{{ .version }} 154 | ``` 155 | 156 | === "`README.md.tmpl`" 157 | ```c 158 | 159 | ``` 160 | 161 | === "`README.md`" 162 | 163 | This is another template kubernetes-v1.0.0 164 | 165 | ### `stripansi ""` 166 | > Strips any Color Codes from a given content 167 | 168 | !!! tip 169 | Useful when a command outputs colored output 170 | 171 | === "`README.md.tmpl`" 172 | ```c 173 | 174 | ``` 175 | 176 | === "`README.md`" 177 | 178 | ```bash 179 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 180 | cf4f9cec8faa registry:2 "/entrypoint.sh /etc…" 7 weeks ago Up 29 minutes 0.0.0.0:5000->5000/tcp, :::5000->5000/tcp registry 181 | ``` 182 | 183 | ### `collapsile "summary" ""` 184 | > Creates a [collapsible](https://gist.github.com/pierrejoubert73/902cc94d79424356a8d20be2b382e1ab) section wit the given summary and content. 185 | 186 | === "`README.md.tmpl`" 187 | ```c 188 | 189 | ``` 190 | 191 | === "`README.md`" 192 | 193 |
194 | output 195 | 196 | ```bash 197 | fmt format go files 198 | help list makefile targets 199 | lint lint go files 200 | test display test coverage 201 | ``` 202 | 203 |
204 | 205 | 206 | ### `toc` 207 | > Inserts a Markdown Table of Content 208 | 209 | !!! note 210 | For now it does not work for any headings that are included after `toc` function invocation. For example when using `file` or `tmpl`/`tmplWithVars` 211 | 212 | === "`README.md.tmpl`" 213 | ```c 214 | # ToC 215 | 216 | # 1. Heading 217 | ## 2. Heading 218 | ### 3. Heading 219 | ## 4. Heading 220 | ``` 221 | 222 | === "`README.md`" 223 | ``` 224 | # ToC 225 | - [ToC](#toc) 226 | - [1. Heading](#1.-heading) 227 | - [2. Heading](#2.-heading) 228 | - [3. Heading](#3.-heading) 229 | - [4. Heading](#4.-heading) 230 | 231 | # 1. Heading 232 | ## 2. Heading 233 | ### 3. Heading 234 | ## 4. Heading 235 | ``` 236 | -------------------------------------------------------------------------------- /pkg/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "regexp" 12 | "slices" 13 | "strings" 14 | "text/template" 15 | "text/template/parse" 16 | 17 | "github.com/FalcoSuessgott/mdtmpl/pkg/commit" 18 | "github.com/Masterminds/semver/v3" 19 | "github.com/acarl005/stripansi" 20 | "github.com/go-sprout/sprout" 21 | "github.com/go-sprout/sprout/group/all" 22 | ) 23 | 24 | const ( 25 | gitCommitMsgFile = ".git/COMMIT_EDITMSG" 26 | gitLatestTagCommand = "git describe --tags --abbrev=0" 27 | ) 28 | 29 | var funcMap template.FuncMap = map[string]any{ 30 | "file": func(file string) (string, error) { 31 | f, err := os.Open(file) 32 | if err != nil { 33 | return "", err 34 | } 35 | 36 | b, err := io.ReadAll(f) 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | return string(b), err 42 | }, 43 | "fileHTTP": func(url string) (string, error) { 44 | //nolint: gosec 45 | resp, err := http.Get(url) 46 | if err != nil { 47 | return "", err 48 | } 49 | defer resp.Body.Close() 50 | 51 | b, err := io.ReadAll(resp.Body) 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | return string(b), nil 57 | }, 58 | "filesInDir": func(dir string, pattern string) ([]string, error) { 59 | var matchedFiles []string 60 | 61 | err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { 62 | if err != nil { 63 | return fmt.Errorf("error accessing path %q: %w", path, err) 64 | } 65 | if !d.IsDir() { 66 | matched, err := filepath.Match(pattern, filepath.Base(path)) 67 | if err != nil { 68 | return fmt.Errorf("error matching pattern %q: %w", pattern, err) 69 | } 70 | if matched { 71 | matchedFiles = append(matchedFiles, path) 72 | } 73 | } 74 | 75 | return nil 76 | }) 77 | 78 | return matchedFiles, err 79 | }, 80 | "exec": func(command string) (string, error) { 81 | cmd := exec.Command("sh", "-c", command) 82 | cmd.Env = os.Environ() 83 | cmd.Dir = os.Getenv("PWD") 84 | 85 | out, err := cmd.Output() 86 | if err != nil { 87 | return "", err 88 | } 89 | 90 | return string(out), nil 91 | }, 92 | "hook": func(command string) (string, error) { 93 | cmd := exec.Command("sh", "-c", command) 94 | cmd.Env = os.Environ() 95 | cmd.Dir = os.Getenv("PWD") 96 | 97 | _, err := cmd.Output() 98 | if err != nil { 99 | return "", err 100 | } 101 | 102 | return "", nil 103 | }, 104 | "code": func(language, content string) string { 105 | return fmt.Sprintf("```%s\n%s\n```", language, content) 106 | }, 107 | "conventionalCommitBump": func() (string, error) { 108 | f, err := os.Open(gitCommitMsgFile) 109 | if err != nil { 110 | return "", err 111 | } 112 | 113 | b, err := io.ReadAll(f) 114 | if err != nil { 115 | return "", err 116 | } 117 | 118 | cmd := strings.Split(gitLatestTagCommand, " ") 119 | //nolint: gosec 120 | version, err := exec.Command(cmd[0], cmd[1:]...).Output() 121 | if err != nil { 122 | return "", fmt.Errorf("failed to get latest tag: %w", err) 123 | } 124 | 125 | semverF, err := commit.ParseConventionalCommit(bytes.TrimSpace(b)) 126 | if err != nil { 127 | return "", fmt.Errorf("failed to parse commit as conventional: %w", err) 128 | } 129 | 130 | sv, err := semver.NewVersion(string(bytes.TrimSpace(version))) 131 | if err != nil { 132 | return "", fmt.Errorf("failed to parse version as semantic version: %w", err) 133 | } 134 | 135 | v := semverF(sv) 136 | if bytes.HasPrefix(version, []byte("v")) { 137 | v = "v" + v 138 | } 139 | 140 | return v, nil 141 | }, 142 | "truncate": strings.TrimSpace, 143 | "stripansi": stripansi.Strip, 144 | "collapsile": func(summary, content string) string { 145 | return fmt.Sprintf("
\n%s\n\n%s\n\n
", summary, content) 146 | }, 147 | } 148 | 149 | type RendererOptions func(*Renderer) 150 | 151 | type Renderer struct { 152 | tmplFile string 153 | } 154 | 155 | func WithTemplateFile(f string) RendererOptions { 156 | return func(p *Renderer) { 157 | p.tmplFile = f 158 | } 159 | } 160 | 161 | // Render renders the given content using the sprig template functions. 162 | // nolint: funlen, cyclop 163 | func Render(content []byte, vars interface{}, opts ...RendererOptions) (bytes.Buffer, error) { 164 | handler := sprout.New() 165 | if err := handler.AddGroups(all.RegistryGroup()); err != nil { 166 | return bytes.Buffer{}, fmt.Errorf("failed to add sprout groups: %w", err) 167 | } 168 | 169 | var buf bytes.Buffer 170 | 171 | tpl, err := newTemplate(content, handler, opts...) 172 | if err != nil { 173 | return buf, err 174 | } 175 | 176 | if err := tpl.Execute(&buf, vars); err != nil { 177 | return buf, err 178 | } 179 | 180 | return buf, nil 181 | } 182 | 183 | // Determines if content has template statements. 184 | func ContainsTemplateActions(content []byte, opts ...RendererOptions) (bool, error) { 185 | handler := sprout.New() 186 | if err := handler.AddGroups(all.RegistryGroup()); err != nil { 187 | return false, fmt.Errorf("failed to add sprout groups: %w", err) 188 | } 189 | 190 | tpl, err := newTemplate(content, handler, opts...) 191 | if err != nil { 192 | return false, err 193 | } 194 | 195 | return containsTemplateActions(tpl.Tree.Root), nil 196 | } 197 | 198 | // nolint: funlen 199 | func newTemplate(content []byte, handler *sprout.DefaultHandler, opts ...RendererOptions) (*template.Template, error) { 200 | var r Renderer 201 | 202 | for _, opt := range opts { 203 | opt(&r) 204 | } 205 | 206 | return template.New("template"). 207 | Option("missingkey=error"). 208 | Funcs(handler.Build()). 209 | Funcs(funcMap). 210 | Funcs(template.FuncMap{ 211 | // we define tmpl here so we dont have a cyclic dependency 212 | "tmpl": func(file string) (string, error) { 213 | f, err := os.Open(file) 214 | if err != nil { 215 | return "", err 216 | } 217 | 218 | b, err := io.ReadAll(f) 219 | if err != nil { 220 | return "", err 221 | } 222 | 223 | res, err := Render(b, nil, opts...) 224 | if err != nil { 225 | return "", fmt.Errorf("failed to render template: %w", err) 226 | } 227 | 228 | return res.String(), nil 229 | }, 230 | "tmplWithVars": func(file string, v interface{}) (string, error) { 231 | f, err := os.Open(file) 232 | if err != nil { 233 | return "", err 234 | } 235 | 236 | b, err := io.ReadAll(f) 237 | if err != nil { 238 | return "", err 239 | } 240 | 241 | res, err := Render(b, v, opts...) 242 | if err != nil { 243 | return "", fmt.Errorf("failed to render template: %w", err) 244 | } 245 | 246 | return res.String(), nil 247 | }, 248 | "toc": func() (string, error) { 249 | // Read the markdown file 250 | out, err := os.ReadFile(r.tmplFile) 251 | if err != nil { 252 | return "", fmt.Errorf("failed to read file %s: %w", r.tmplFile, err) 253 | } 254 | 255 | // Regular expression to match markdown headings 256 | re := regexp.MustCompile(`(?m)^(#{1,6})\s+(.*)`) 257 | 258 | // Find all headings 259 | matches := re.FindAllStringSubmatch(string(out), -1) 260 | 261 | // Generate the table of contents 262 | var toc strings.Builder 263 | 264 | for _, match := range matches { 265 | level := len(match[1]) 266 | heading := match[2] 267 | anchor := strings.ToLower(strings.ReplaceAll(heading, " ", "-")) 268 | toc.WriteString(fmt.Sprintf("%s- [%s](#%s)\n", strings.Repeat(" ", level-1), heading, anchor)) 269 | } 270 | 271 | return toc.String(), nil 272 | }, 273 | }). 274 | Parse(string(content)) 275 | } 276 | 277 | func containsTemplateActions(n parse.Node) bool { 278 | switch node := n.(type) { 279 | case *parse.ListNode: 280 | if slices.ContainsFunc(node.Nodes, containsTemplateActions) { 281 | return true 282 | } 283 | case *parse.ActionNode, *parse.IfNode, *parse.RangeNode, 284 | *parse.WithNode, *parse.TemplateNode, *parse.BranchNode: 285 | return true 286 | } 287 | 288 | return false 289 | } 290 | --------------------------------------------------------------------------------