├── .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 |
3 |
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 |
--------------------------------------------------------------------------------