├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── images ├── til_build.png ├── til_save.png └── till_header.png ├── main.go ├── pages ├── page.go ├── tag.go └── tag_map.go ├── src ├── colours.go ├── configuration.go ├── logging.go ├── page_elements.go └── target_directory.go └── til_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: senorprogrammer 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: senorprogrammer 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | bench.txt 3 | bin/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | env: 2 | - GO111MODULE=on 3 | - GOPROXY="https://proxy.golang.org,direct" 4 | 5 | archives: 6 | - id: default 7 | wrap_in_directory: true 8 | 9 | builds: 10 | - binary: til 11 | goos: 12 | - darwin 13 | - linux 14 | goarch: 15 | - 386 16 | - amd64 17 | - arm 18 | - arm64 19 | 20 | before: 21 | hooks: 22 | - make build 23 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at chriscummer+til@me.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Chris Cummer 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build help install lint test uninstall 2 | 3 | # Set go modules to on and use GoCenter for immutable modules 4 | export GO111MODULE = on 5 | export GOPROXY = https://proxy.golang.org,direct 6 | 7 | # Determines the path to this Makefile 8 | THIS_FILE := $(lastword $(MAKEFILE_LIST)) 9 | 10 | APP=til 11 | 12 | ## build: builds a local version 13 | build: 14 | go build -o bin/${APP} 15 | @echo "Done building" 16 | 17 | ## help: prints this help message 18 | help: 19 | @echo "Usage: \n" 20 | @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' 21 | 22 | ## install: installs a local version of the app 23 | install: 24 | @echo "Installing ${APP}..." 25 | @go clean 26 | @go install -ldflags="-s -w" 27 | $(eval INSTALLPATH = $(shell which ${APP})) 28 | @echo "${APP} installed into ${INSTALLPATH}" 29 | 30 | ## lint: runs a number of code quality checks against the source code 31 | lint: 32 | golangci-lint run 33 | 34 | ## test: runs the test suite 35 | test: build 36 | go test ./... 37 | 38 | ## uninstall: uninstals a locally-installed version 39 | uninstall: 40 | @rm ~/go/bin/${APP} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

til

2 | 3 | `til` is a fast, simple, command line-driven, mini-static site generator for quickly capturing and publishing one-off notes. 4 | 5 | All in only two commands. 6 | 7 | Example output: [https://github.com/senorprogrammer/tilde](https://github.com/senorprogrammer/tilde) 8 | 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/senorprogrammer/til)](https://goreportcard.com/report/github.com/senorprogrammer/til) 10 | 11 | # tl;dr 12 | 13 | ```bash 14 | ❯ til New title here 15 | ...edit 16 | ❯ til -save 17 | ``` 18 | 19 | And you're done. 20 | 21 | # Contents 22 | 23 | * [Installation](#installation) 24 | * [From source](#from-source) 25 | * [As a binary](#as-a-binary) 26 | * [Configuration](#configuration) 27 | * [Example](#config-example) 28 | * [Usage](#usage) 29 | * [Creating a new page](#creating-a-new-page) 30 | * [Building static pages](#building-static-pages) 31 | * [Building, saving, committing, and pushing](#building-saving-committing-and-pushing) 32 | * [Publishing to GitHub Pages](#publishing-to-github-pages) 33 | * [Live Example](#live-example) 34 | * [Frequently Unasked Questions](#frequently-unasked-questions) 35 | 36 | ## Installation 37 | 38 | ### From source 39 | 40 | ``` 41 | go get -u github.com/senorprogrammer/til 42 | cd $GOPATH/src/github.com/senorprogrammer/til 43 | go install . 44 | which til 45 | til --help 46 | ``` 47 | 48 | ### As a Binary 49 | 50 | [Download the latest binary](https://github.com/senorprogrammer/til/releases) from GitHub. 51 | 52 | til is a stand-alone binary. Once downloaded, copy it to a location you can run executables from (ie: `/usr/local/bin/`), and set the permissions accordingly: 53 | 54 | ```bash 55 | chmod a+x /usr/local/bin/til 56 | ``` 57 | 58 | and you should be good to go. 59 | 60 | ## Configuration 61 | 62 | When you first run `til --help` it will display the help and usage info. It also will also create a default configuration file. 63 | 64 | You will need to make some changes to this configuration file. 65 | 66 | The config file lives in `~/.config/til/config.yml` (if you're an XDG kind of person, it will be wherever you've set that to). 67 | 68 | Open `~/.config/til/config.yml`, change the following entries, and save it: 69 | 70 | * committerEmail 71 | * committerName 72 | * editor 73 | * targetDirectories 74 | 75 | `committerEmail` and `committerName` are the values `til` will use to commit changes with when you run `til -save`. 76 | 77 | `editor` is the text editor `til` will open your file in when you run `til [some title here]`. 78 | 79 | `targetDirectories` defines the locations that `til` will write your files to. If a specified target directory does not exist, `til` will try to create it. This is a map of key/value pairs, where the "key" defines the value to pass in using the `-target` flag, and the "value" is the path to the directory. 80 | 81 | If only one target directory is defined in the configuration, the `-target` flag can be ommitted from all commands. 82 | If multiple target diretories are defined in the configuration, all commands must include the `-target` flag specifying 83 | which target directory to operate against. 84 | 85 | ### Config Example 86 | 87 | ``` 88 | --- 89 | commitMessage: "build, save, push" 90 | committerEmail: test@example.com 91 | committerName: "TIL Autobot" 92 | editor: "mvim" 93 | targetDirectories: 94 | a: ~/Documents/notes 95 | b: ~/Documents/blog 96 | ``` 97 | 98 | ## Usage 99 | 100 | `til` only has three usage options: `til`, `til -build`, and `til -save`. 101 | 102 | ### Creating a new page 103 | 104 | With one target directory defined in the configuration: 105 | 106 | ```bash 107 | ❯ til New title here 108 | 2020-04-20T14-52-57-new-title-here.md 109 | ``` 110 | 111 | With multiple target directories defined: 112 | 113 | ```bash 114 | ❯ til -target a New title here 115 | 2020-04-20T14-52-57-new-title-here.md 116 | ``` 117 | 118 | That new page will open in whichever editor you've defined in your config. 119 | 120 | ### Building static pages 121 | 122 | With one target directory defined in the configuration: 123 | 124 | ```bash 125 | ❯ til -build 126 | ``` 127 | 128 | With multiple target directories defined: 129 | 130 | ```bash 131 | ❯ til -target a -build 132 | ``` 133 | 134 | Builds the index and tag pages, and leaves them uncommitted. 135 | 136 |

image of the build process

137 | 138 | ### Building, saving, committing, and pushing 139 | 140 | With one target directory defined in the configuration: 141 | 142 | ```bash 143 | ❯ til -save [optional commit message] 144 | ``` 145 | 146 | With multiple target directories defined: 147 | 148 | ```bash 149 | ❯ til -target a -save [optional commit message] 150 | ``` 151 | 152 | Builds the index and tag pages, commits everything to the git repo with the commit message you've defined in your config, and pushes it all up to the remote repo. 153 | 154 | `-save` makes a hard assumption that your target directory is under version control, controlled by `git`. It is recommended that you do this. 155 | 156 | `-save` also makes a soft assumption that your target directory has `remote` set to GitHub (but it should work with `remote` set to anywhere). 157 | 158 | `-save` takes an optional commit message. If that message is supplied, it will be used as the commit message. If that message is not supplied, the `commitMessage` value in the config file will be used. If that value is not supplied, an error will be raised. 159 | 160 |

image of the save process

161 | 162 | ## Publishing to GitHub Pages 163 | 164 | The generated output of `til` is such that if your `git remote` is configured to use GitHub, it should be fully compatible with GitHub Pages. 165 | 166 | Follow the [GitHub Pages setup instructions](https://guides.github.com/features/pages/), using the `/docs` option for **Source**, and it should "just work". 167 | 168 | ## Live Example 169 | 170 | An example published site: [https://senorprogrammer.github.io/tilde/](https://senorprogrammer.github.io/tilde/). And the raw source: [github.com/senorprogrammer/tilde](https://github.com/senorprogrammer/tilde) 171 | 172 | ## Frequently Unasked Questions 173 | 174 | ### Isn't this just (insert your favourite not this thing here)? 175 | 176 | Yep, probably. I'm sure you could also put something like this together with [Hugo](https://gohugo.io), or [Jekyll](https://jekyllrb.com), or bash scripts, or emacs and some lisp macros.... Cool, eh? 177 | 178 | ### Does it have search? 179 | 180 | It does not. 181 | 182 | ### Does this work on Windows? 183 | 184 | Good question. No idea. Let me know? 185 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/senorprogrammer/til 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/ericaro/frontmatter v0.0.0-20200210094738-46863cd917e2 7 | github.com/go-git/go-git/v5 v5.0.0 8 | github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4 9 | github.com/stretchr/testify v1.4.0 10 | gopkg.in/yaml.v2 v2.2.8 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= 2 | github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= 3 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= 4 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= 5 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 6 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= 12 | github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= 13 | github.com/ericaro/frontmatter v0.0.0-20200210094738-46863cd917e2 h1:+dzAHE3uQKGtPxobmh43c0n8V8A7X70zqUUDzL7ePVA= 14 | github.com/ericaro/frontmatter v0.0.0-20200210094738-46863cd917e2/go.mod h1:Xy0DdToffIGb/ZMCwM5zFgIQOrVh0cKvNABpKRJYo1w= 15 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= 16 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 17 | github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= 18 | github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= 19 | github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= 20 | github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= 21 | github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM= 22 | github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 23 | github.com/go-git/go-git-fixtures/v4 v4.0.1 h1:q+IFMfLx200Q3scvt2hN79JsEzy4AmBTp/pqnefH+Bc= 24 | github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= 25 | github.com/go-git/go-git/v5 v5.0.0 h1:k5RWPm4iJwYtfWoxIJy4wJX9ON7ihPeZZYC1fLYDnpg= 26 | github.com/go-git/go-git/v5 v5.0.0/go.mod h1:oYD8y9kWsGINPFJoLdaScGCN6dlKg23blmClfZwtUVA= 27 | github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 28 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 29 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 30 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 31 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 32 | github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= 33 | github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 34 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 35 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 36 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 37 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 38 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 39 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 40 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 41 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 42 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 43 | github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4 h1:JnVsYEQzhEcOspy6ngIYNF2u0h2mjkXZptzX0IzZQ4g= 44 | github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4/go.mod h1:RL5+WRxWTAXqqCi9i+eZlHrUtO7AQujUqWi+xMohmc4= 45 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 46 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 50 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 51 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 52 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 53 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 54 | github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= 55 | github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= 56 | golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 57 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 58 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= 59 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 60 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 61 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= 62 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 63 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 64 | golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 65 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 66 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= 67 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 68 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 69 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 70 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 71 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 72 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 73 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 74 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 75 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 76 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 77 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 78 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 79 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 80 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 81 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 82 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 83 | -------------------------------------------------------------------------------- /images/til_build.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/senorprogrammer/til/c06981b340754a0121a4e296aeaae3178073ff36/images/til_build.png -------------------------------------------------------------------------------- /images/til_save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/senorprogrammer/til/c06981b340754a0121a4e296aeaae3178073ff36/images/til_save.png -------------------------------------------------------------------------------- /images/till_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/senorprogrammer/til/c06981b340754a0121a4e296aeaae3178073ff36/images/till_header.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/go-git/go-git/v5" 16 | "github.com/go-git/go-git/v5/plumbing/object" 17 | "github.com/olebedev/config" 18 | "github.com/senorprogrammer/til/pages" 19 | "github.com/senorprogrammer/til/src" 20 | ) 21 | 22 | const ( 23 | defaultCommitMsg = "build, save, push" 24 | 25 | defaultEditor = "open" 26 | 27 | /* -------------------- Messages -------------------- */ 28 | 29 | errConfigValueRead = "could not read a required configuration value" 30 | errNoTitle = "title must not be blank" 31 | 32 | statusDone = "done" 33 | statusIdxBuild = "building index page" 34 | statusRepoPush = "pushing to remote" 35 | statusRepoSave = "saving uncommitted files" 36 | statusTagBuild = "building tag pages" 37 | ) 38 | 39 | var ( 40 | buildFlag bool 41 | listFlag bool 42 | saveFlag bool 43 | targetDirFlag string 44 | ) 45 | 46 | func init() { 47 | src.LL = log.New(os.Stdout, "", log.LstdFlags|log.Lshortfile) 48 | 49 | flag.BoolVar(&buildFlag, "b", false, "builds the index and tag pages (short-hand)") 50 | flag.BoolVar(&buildFlag, "build", false, "builds the index and tag pages") 51 | 52 | flag.BoolVar(&listFlag, "l", false, "lists the configured target directories (short-hand)") 53 | flag.BoolVar(&listFlag, "list", false, "lists the configured target directories") 54 | 55 | flag.BoolVar(&saveFlag, "s", false, "builds, saves, and pushes (short-hand)") 56 | flag.BoolVar(&saveFlag, "save", false, "builds, saves, and pushes") 57 | 58 | flag.StringVar(&targetDirFlag, "t", "", "specifies the target directory key (short-hand)") 59 | flag.StringVar(&targetDirFlag, "target", "", "specifies the target directory key") 60 | } 61 | 62 | /* -------------------- Main -------------------- */ 63 | 64 | func main() { 65 | flag.Parse() 66 | 67 | cnf := &src.Config{} 68 | cnf.Load() 69 | 70 | /* Flaghandling */ 71 | /* I personally think "flag handling" should be spelled flag-handling 72 | but precedence has been set and we will defer to it. 73 | According to wiktionary.org, "stick-handling" is correctly spelled 74 | "stickhandling", so here we are, abomination enshrined */ 75 | 76 | if listFlag { 77 | listTargetDirectories(src.GlobalConfig) 78 | src.Victory(statusDone) 79 | } 80 | 81 | if buildFlag { 82 | buildContent() 83 | src.Victory(statusDone) 84 | } 85 | 86 | if saveFlag { 87 | commitMsg := determineCommitMessage(src.GlobalConfig, os.Args) 88 | 89 | buildContent() 90 | save(commitMsg) 91 | push() 92 | src.Victory(statusDone) 93 | } 94 | 95 | src.BuildTargetDirectory() 96 | 97 | /* Page creation */ 98 | 99 | title := parseTitle(targetDirFlag, os.Args) 100 | if title == "" { 101 | // Every non-dash argument is considered a part of the title. If there are no arguments, we have no title 102 | // Can't have a page without a title 103 | src.Defeat(errors.New(errNoTitle)) 104 | } 105 | 106 | createNewPage(title) 107 | 108 | src.Victory(statusDone) 109 | } 110 | 111 | /* -------------------- Helper functions -------------------- */ 112 | 113 | func buildContent() { 114 | pages := loadPages() 115 | tagMap := buildTagPages(pages) 116 | 117 | buildIndexPage(pages, tagMap) 118 | } 119 | 120 | // buildIndexPage creates the main index.md page that is the root of the site 121 | func buildIndexPage(pageSet []*pages.Page, tagMap *pages.TagMap) { 122 | src.Info(statusIdxBuild) 123 | 124 | content := "" 125 | 126 | // Write the tag list into the top of the index 127 | tagLinks := []string{} 128 | 129 | for _, tagName := range tagMap.SortedTagNames() { 130 | tags := tagMap.Get(tagName) 131 | if len(tags) > 0 { 132 | tagLinks = append(tagLinks, tags[0].Link()) 133 | } 134 | } 135 | 136 | content += strings.Join(tagLinks, ", ") 137 | content += "\n" 138 | 139 | // Write the page list into the middle of the page 140 | content += pagesToHTMLUnorderedList(pageSet) 141 | content += "\n" 142 | 143 | // Write the footer content into the bottom of the index 144 | content += "\n" 145 | content += src.Footer() 146 | 147 | // And write the file to disk 148 | tDir, err := src.GetTargetDir(src.GlobalConfig, targetDirFlag, true) 149 | if err != nil { 150 | src.Defeat(err) 151 | } 152 | 153 | filePath := fmt.Sprintf( 154 | "%s/index.%s", 155 | tDir, 156 | pages.FileExtension, 157 | ) 158 | 159 | err = ioutil.WriteFile(filePath, []byte(content), 0644) 160 | if err != nil { 161 | src.Defeat(err) 162 | } 163 | 164 | src.Progress(filePath) 165 | } 166 | 167 | // buildTagPages creates the tag pages, with links to posts tagged with those names 168 | func buildTagPages(pageSet []*pages.Page) *pages.TagMap { 169 | src.Info(statusTagBuild) 170 | 171 | tagMap := pages.NewTagMap(pageSet) 172 | 173 | var wGroup sync.WaitGroup 174 | 175 | for _, tagName := range tagMap.SortedTagNames() { 176 | wGroup.Add(1) 177 | 178 | go func(tagName string) { 179 | defer wGroup.Done() 180 | 181 | content := fmt.Sprintf("## %s\n\n", tagName) 182 | 183 | // Write the page list into the middle of the page 184 | content += pagesToHTMLUnorderedList(tagMap.PagesFor(tagName)) 185 | 186 | // Write the footer content into the bottom of the page 187 | content += "\n" 188 | content += src.Footer() 189 | 190 | // And write the file to disk 191 | tDir, err := src.GetTargetDir(src.GlobalConfig, targetDirFlag, true) 192 | if err != nil { 193 | src.Defeat(err) 194 | } 195 | 196 | filePath := fmt.Sprintf( 197 | "%s/%s.%s", 198 | tDir, 199 | tagName, 200 | pages.FileExtension, 201 | ) 202 | 203 | err = ioutil.WriteFile(filePath, []byte(content), 0644) 204 | if err != nil { 205 | src.Defeat(err) 206 | } 207 | 208 | src.Progress(filePath) 209 | }(tagName) 210 | } 211 | 212 | wGroup.Wait() 213 | 214 | return tagMap 215 | } 216 | 217 | func createNewPage(title string) { 218 | tDir, err := src.GetTargetDir(src.GlobalConfig, targetDirFlag, true) 219 | if err != nil { 220 | src.Defeat(err) 221 | } 222 | 223 | page := pages.NewPage(title, tDir) 224 | 225 | err = page.Open(defaultEditor) 226 | if err != nil { 227 | src.Defeat(err) 228 | } 229 | 230 | // Write the page path to the console. This makes it easy to know which file we just created 231 | src.Info(page.FilePath) 232 | } 233 | 234 | // determineCommitMessage figures out which commit message to save the repo with 235 | // The order of precedence is: 236 | // * message passed in via the -s flag 237 | // * message defined in config.yml for the commitMessage key 238 | // * message as a hard-coded constant, at top, in defaultCommitMsg 239 | // Example: 240 | // > til -t b -s this is message 241 | func determineCommitMessage(cfg *config.Config, args []string) string { 242 | if flag.NArg() == 0 { 243 | return cfg.UString("commitMessage", defaultCommitMsg) 244 | } 245 | 246 | msgOffset := len(args) - flag.NArg() 247 | msg := strings.Join(args[msgOffset:], " ") 248 | 249 | return msg 250 | } 251 | 252 | // listTargetDirectories writes the list of target directories in the configuration 253 | // out to the terminal 254 | func listTargetDirectories(cfg *config.Config) { 255 | dirMap, err := cfg.Map("targetDirectories") 256 | if err != nil { 257 | src.Defeat(err) 258 | } 259 | 260 | for key, dir := range dirMap { 261 | src.Info(fmt.Sprintf("%6s\t%s\n", key, dir.(string))) 262 | } 263 | } 264 | 265 | // loadPages reads the page files from disk (in reverse chronological order) and 266 | // creates Page instances from them 267 | func loadPages() []*pages.Page { 268 | pageSet := []*pages.Page{} 269 | 270 | tDir, err := src.GetTargetDir(src.GlobalConfig, targetDirFlag, true) 271 | if err != nil { 272 | src.Defeat(err) 273 | } 274 | 275 | filePaths, _ := filepath.Glob( 276 | fmt.Sprintf( 277 | "%s/*.%s", 278 | tDir, 279 | pages.FileExtension, 280 | ), 281 | ) 282 | 283 | for i := len(filePaths) - 1; i >= 0; i-- { 284 | page := pages.PageFromFilePath(filePaths[i]) 285 | pageSet = append(pageSet, page) 286 | } 287 | 288 | return pageSet 289 | } 290 | 291 | // // open tll the OS to open the newly-created page in the editor (as specified in the config) 292 | // // If there's no editor explicitly defined by the user, tell the OS to try and open it 293 | // func open(page *src.Page) error { 294 | // editor := src.GlobalConfig.UString("editor", defaultEditor) 295 | // if editor == "" { 296 | // editor = defaultEditor 297 | // } 298 | 299 | // cmd := exec.Command(editor, page.FilePath) 300 | // err := cmd.Run() 301 | 302 | // return err 303 | // } 304 | 305 | // pagesToHTMLUnorderedList creates the unordered list of page links that appear 306 | // on the index and tag pages 307 | func pagesToHTMLUnorderedList(pageSet []*pages.Page) string { 308 | content := "" 309 | prevPage := &pages.Page{} 310 | 311 | for _, page := range pageSet { 312 | if !page.IsContentPage() { 313 | continue 314 | } 315 | 316 | // This breaks the page list up by month 317 | if prevPage.CreatedMonth() != page.CreatedMonth() { 318 | content += "\n" 319 | } 320 | 321 | content += fmt.Sprintf("* %s\n", page.Link()) 322 | 323 | prevPage = page 324 | } 325 | 326 | return content 327 | } 328 | 329 | func parseTitle(targetFlag string, args []string) string { 330 | titleOffset := 3 331 | if targetFlag == "" { 332 | titleOffset = 1 333 | } 334 | 335 | return strings.Title(strings.Join(args[titleOffset:], " ")) 336 | } 337 | 338 | // push pushes up to the remote git repo 339 | func push() { 340 | src.Info(statusRepoPush) 341 | 342 | tDir, err := src.GetTargetDir(src.GlobalConfig, targetDirFlag, false) 343 | if err != nil { 344 | src.Defeat(err) 345 | } 346 | 347 | r, err := git.PlainOpen(tDir) 348 | if err != nil { 349 | src.Defeat(err) 350 | } 351 | 352 | err = r.Push(&git.PushOptions{}) 353 | if err != nil { 354 | src.Defeat(err) 355 | } 356 | } 357 | 358 | // https://github.com/go-git/go-git/blob/master/_examples/commit/main.go 359 | func save(commitMsg string) { 360 | src.Info(statusRepoSave) 361 | 362 | tDir, err := src.GetTargetDir(src.GlobalConfig, targetDirFlag, false) 363 | if err != nil { 364 | src.Defeat(err) 365 | } 366 | 367 | r, err := git.PlainOpen(tDir) 368 | if err != nil { 369 | src.Defeat(err) 370 | } 371 | 372 | w, err := r.Worktree() 373 | if err != nil { 374 | src.Defeat(err) 375 | } 376 | 377 | _, err = w.Add(".") 378 | if err != nil { 379 | src.Defeat(err) 380 | } 381 | 382 | defaultCommitEmail, err2 := src.GlobalConfig.String("committerEmail") 383 | defaultCommitName, err3 := src.GlobalConfig.String("committerName") 384 | if err2 != nil || err3 != nil { 385 | src.Defeat(errors.New(errConfigValueRead)) 386 | } 387 | 388 | commit, err := w.Commit(commitMsg, &git.CommitOptions{ 389 | Author: &object.Signature{ 390 | Name: defaultCommitName, 391 | Email: defaultCommitEmail, 392 | When: time.Now(), 393 | }, 394 | }) 395 | if err != nil { 396 | src.Defeat(err) 397 | } 398 | 399 | obj, err := r.CommitObject(commit) 400 | if err != nil { 401 | src.Defeat(err) 402 | } 403 | 404 | src.Info(fmt.Sprintf("committed with '%s' (%.7s)", obj.Message, obj.Hash.String())) 405 | } 406 | -------------------------------------------------------------------------------- /pages/page.go: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/ericaro/frontmatter" 12 | "github.com/senorprogrammer/til/src" 13 | ) 14 | 15 | const ( 16 | // A custom datetime format that plays nicely with GitHub Pages filename restrictions 17 | ghFriendlyDateFormat = "2006-01-02T15-04-05" 18 | 19 | // FileExtension defines the extension to write on the generated file 20 | FileExtension = "md" 21 | ) 22 | 23 | // Page represents a TIL page 24 | type Page struct { 25 | Content string `fm:"content" yaml:"-"` 26 | Date string `yaml:"date"` 27 | FilePath string `yaml:"filepath"` 28 | TagsStr string `yaml:"tags"` 29 | Title string `yaml:"title"` 30 | } 31 | 32 | // NewPage creates and returns an instance of page 33 | func NewPage(title string, targetDir string) *Page { 34 | date := time.Now() 35 | 36 | page := &Page{ 37 | Date: date.Format(time.RFC3339), 38 | FilePath: fmt.Sprintf( 39 | "%s/%s-%s.%s", 40 | targetDir, 41 | date.Format(ghFriendlyDateFormat), 42 | strings.ReplaceAll(strings.ToLower(title), " ", "-"), 43 | FileExtension, 44 | ), 45 | Title: title, 46 | } 47 | 48 | page.Save() 49 | 50 | return page 51 | } 52 | 53 | // PageFromFilePath creates and returns a Page instance from a file path 54 | func PageFromFilePath(filePath string) *Page { 55 | page := new(Page) 56 | 57 | data, err := ioutil.ReadFile(filePath) 58 | if err != nil { 59 | src.Defeat(err) 60 | } 61 | 62 | err = frontmatter.Unmarshal(data, page) 63 | if err != nil { 64 | src.Defeat(err) 65 | } 66 | 67 | page.FilePath = filePath 68 | 69 | return page 70 | } 71 | 72 | // CreatedAt returns a time instance representing when the page was created 73 | func (page *Page) CreatedAt() time.Time { 74 | date, err := time.Parse(time.RFC3339, page.Date) 75 | if err != nil { 76 | return time.Time{} 77 | } 78 | 79 | return date 80 | } 81 | 82 | // CreatedMonth returns the month the page was created 83 | func (page *Page) CreatedMonth() time.Month { 84 | if page.CreatedAt().IsZero() { 85 | return 0 86 | } 87 | 88 | return page.CreatedAt().Month() 89 | } 90 | 91 | // FrontMatter returns the front-matter of the page 92 | func (page *Page) FrontMatter() string { 93 | return fmt.Sprintf( 94 | "---\nlayout: default\ndate: %s\ntitle: %s\ntags: %s\n---\n\n", 95 | page.Date, 96 | page.Title, 97 | page.TagsStr, 98 | ) 99 | } 100 | 101 | // IsContentPage returns true if the page is a valid entry page, false if it is not 102 | func (page *Page) IsContentPage() bool { 103 | return page.Title != "" 104 | } 105 | 106 | // Link returns a link string suitable for embedding in a Markdown page 107 | func (page *Page) Link() string { 108 | return fmt.Sprintf( 109 | "%s [%s](%s)", 110 | page.PrettyDate(), 111 | page.Title, 112 | filepath.Base(page.FilePath), 113 | ) 114 | } 115 | 116 | // Open tll the OS to open the newly-created page in the editor (as specified in the config) 117 | // If there's no editor explicitly defined by the user, tell the OS to try and open it 118 | func (page *Page) Open(defaultEditor string) error { 119 | editor := src.GlobalConfig.UString("editor", defaultEditor) 120 | if editor == "" { 121 | editor = defaultEditor 122 | } 123 | 124 | cmd := exec.Command(editor, page.FilePath) 125 | err := cmd.Run() 126 | 127 | return err 128 | } 129 | 130 | // PrettyDate returns a human-friendly representation of the CreatedAt date 131 | func (page *Page) PrettyDate() string { 132 | return page.CreatedAt().Format("Jan 02, 2006") 133 | } 134 | 135 | // Save writes the content of the page to file 136 | func (page *Page) Save() { 137 | pageSrc := page.FrontMatter() 138 | pageSrc += fmt.Sprintf("# %s\n\n", page.Title) 139 | 140 | err := ioutil.WriteFile(page.FilePath, []byte(pageSrc), 0644) 141 | if err != nil { 142 | src.Defeat(err) 143 | } 144 | } 145 | 146 | // Tags returns a slice of tags assigned to this page 147 | func (page *Page) Tags() []*Tag { 148 | tags := []*Tag{} 149 | 150 | names := strings.Split(page.TagsStr, ",") 151 | for _, name := range names { 152 | tags = append(tags, NewTag(name, page)) 153 | } 154 | 155 | return tags 156 | } 157 | -------------------------------------------------------------------------------- /pages/tag.go: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Tag represents a page tag (e.g.: linux, zombies) 9 | type Tag struct { 10 | Name string 11 | Pages []*Page 12 | } 13 | 14 | // NewTag creates and returns an instance of Tag 15 | func NewTag(name string, page *Page) *Tag { 16 | tag := &Tag{ 17 | Name: strings.TrimSpace(name), 18 | Pages: []*Page{page}, 19 | } 20 | 21 | return tag 22 | } 23 | 24 | // AddPage adds a page to the list of pages 25 | func (tag *Tag) AddPage(page *Page) { 26 | tag.Pages = append(tag.Pages, page) 27 | } 28 | 29 | // IsValid returns true if this is a valid tag, false if it is not 30 | func (tag *Tag) IsValid() bool { 31 | return tag.Name != "" 32 | } 33 | 34 | // Link returns a link string suitable for embedding in a Markdown page 35 | func (tag *Tag) Link() string { 36 | if tag.Name == "" { 37 | return "" 38 | } 39 | 40 | return fmt.Sprintf( 41 | "[%s](%s)", 42 | tag.Name, 43 | fmt.Sprintf("./%s", tag.Name), 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /pages/tag_map.go: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | // TagMap is a map of tag name to Tag instance 8 | type TagMap struct { 9 | Tags map[string][]*Tag 10 | } 11 | 12 | // NewTagMap creates and returns an instance of TagMap 13 | func NewTagMap(pageSet []*Page) *TagMap { 14 | tm := &TagMap{ 15 | Tags: make(map[string][]*Tag), 16 | } 17 | 18 | tm.BuildFromPages(pageSet) 19 | 20 | return tm 21 | } 22 | 23 | // Add adds a Tag instance to the map 24 | func (tm *TagMap) Add(tag *Tag) { 25 | if !tag.IsValid() { 26 | return 27 | } 28 | 29 | tm.Tags[tag.Name] = append(tm.Tags[tag.Name], tag) 30 | } 31 | 32 | // BuildFromPages populates the tag map from a slice of Page instances 33 | func (tm *TagMap) BuildFromPages(pages []*Page) { 34 | for _, page := range pages { 35 | for _, tag := range page.Tags() { 36 | tm.Add(tag) 37 | } 38 | } 39 | } 40 | 41 | // Get returns the tags for a given tag name 42 | func (tm *TagMap) Get(name string) []*Tag { 43 | return tm.Tags[name] 44 | } 45 | 46 | // Len returns the number of tags in the map 47 | func (tm *TagMap) Len() int { 48 | return len(tm.Tags) 49 | } 50 | 51 | // PagesFor returns a flattened slice of pages for a given tag name, sorted 52 | // in reverse-chronological order 53 | func (tm *TagMap) PagesFor(tagName string) []*Page { 54 | pages := []*Page{} 55 | tags := tm.Get(tagName) 56 | 57 | for _, tag := range tags { 58 | pages = append(pages, tag.Pages...) 59 | 60 | sort.Slice(pages, func(i, j int) bool { 61 | return pages[i].CreatedAt().After(pages[j].CreatedAt()) 62 | }) 63 | } 64 | 65 | return pages 66 | } 67 | 68 | // SortedTagNames returns the tag names in alphabetical order 69 | func (tm *TagMap) SortedTagNames() []string { 70 | tagArr := make([]string, tm.Len()) 71 | i := 0 72 | 73 | for tag := range tm.Tags { 74 | tagArr[i] = tag 75 | i++ 76 | } 77 | 78 | sort.Strings(tagArr) 79 | 80 | return tagArr 81 | } 82 | -------------------------------------------------------------------------------- /src/colours.go: -------------------------------------------------------------------------------- 1 | package src 2 | 3 | import "fmt" 4 | 5 | var ( 6 | // Blue writes blue text 7 | Blue = Colour("\033[1;36m%s\033[0m") 8 | 9 | // Green writes green text 10 | Green = Colour("\033[1;32m%s\033[0m") 11 | 12 | // Red writes red text 13 | Red = Colour("\033[1;31m%s\033[0m") 14 | ) 15 | 16 | // Colour returns a function that defines a printable colour string 17 | func Colour(colorString string) func(...interface{}) string { 18 | sprint := func(args ...interface{}) string { 19 | return fmt.Sprintf(colorString, 20 | fmt.Sprint(args...)) 21 | } 22 | return sprint 23 | } 24 | -------------------------------------------------------------------------------- /src/configuration.go: -------------------------------------------------------------------------------- 1 | package src 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/olebedev/config" 11 | ) 12 | 13 | const ( 14 | defaultConfig = `--- 15 | commitMessage: "build, save, push" 16 | committerEmail: test@example.com 17 | committerName: "TIL Autobot" 18 | editor: "" 19 | targetDirectory: 20 | a: "~/Documents/tilblog" 21 | ` 22 | 23 | tilConfigDir = "~/.config/til/" 24 | tilConfigFile = "config.yml" 25 | ) 26 | 27 | const ( 28 | errConfigDirCreate = "could not create the configuration directory" 29 | errConfigExpandPath = "could not expand the config directory" 30 | errConfigFileAssert = "could not assert the configuration file exists" 31 | errConfigFileCreate = "could not create the configuration file" 32 | errConfigFileWrite = "could not write the configuration file" 33 | errConfigPathEmpty = "config path cannot be empty" 34 | ) 35 | 36 | // GlobalConfig holds and makes available all the user-configurable 37 | // settings that are stored in the config file. 38 | // (I know! Friends don't let friends use globals, but since I have 39 | // no friends working on this, there's no one around to stop me) 40 | var GlobalConfig *config.Config 41 | 42 | // Config handles all things to do with configuration 43 | type Config struct{} 44 | 45 | // Load reads the configuration file 46 | func (c *Config) Load() { 47 | makeConfigDir() 48 | makeConfigFile() 49 | 50 | GlobalConfig = readConfigFile() 51 | } 52 | 53 | // getConfigDir returns the string path to the directory that should 54 | // contain the configuration file. 55 | // It tries to be XDG-compatible 56 | func getConfigDir() (string, error) { 57 | cDir := os.Getenv("XDG_CONFIG_HOME") 58 | if cDir == "" { 59 | cDir = tilConfigDir 60 | } 61 | 62 | // If the user hasn't changed the default path then we expect it to start 63 | // with a tilde (the user's home), and we need to turn that into an 64 | // absolute path. If it does not start with a '~' then we assume the 65 | // user has set their $XDG_CONFIG_HOME to something specific, and we 66 | // do not mess with it (because doing so makes the archlinux people 67 | // very cranky) 68 | if cDir[0] != '~' { 69 | return cDir, nil 70 | } 71 | 72 | dir, err := os.UserHomeDir() 73 | if err != nil { 74 | return "", errors.New(errConfigExpandPath) 75 | } 76 | 77 | cDir = filepath.Join(dir, cDir[1:]) 78 | 79 | if cDir == "" { 80 | return "", errors.New(errConfigPathEmpty) 81 | } 82 | 83 | return cDir, nil 84 | } 85 | 86 | // GetConfigFilePath returns the string path to the configuration file 87 | func GetConfigFilePath() (string, error) { 88 | cDir, err := getConfigDir() 89 | if err != nil { 90 | return "", err 91 | } 92 | 93 | if cDir == "" { 94 | return "", errors.New(errConfigPathEmpty) 95 | } 96 | 97 | return fmt.Sprintf("%s/%s", cDir, tilConfigFile), nil 98 | } 99 | 100 | func makeConfigDir() { 101 | cDir, err := getConfigDir() 102 | if err != nil { 103 | Defeat(err) 104 | } 105 | 106 | if cDir == "" { 107 | Defeat(errors.New(errConfigPathEmpty)) 108 | } 109 | 110 | if _, err := os.Stat(cDir); os.IsNotExist(err) { 111 | err := os.MkdirAll(cDir, os.ModePerm) 112 | if err != nil { 113 | Defeat(errors.New(errConfigDirCreate)) 114 | } 115 | 116 | Progress(fmt.Sprintf("created %s", cDir)) 117 | } 118 | } 119 | 120 | func makeConfigFile() { 121 | cPath, err := GetConfigFilePath() 122 | if err != nil { 123 | Defeat(err) 124 | } 125 | 126 | if cPath == "" { 127 | Defeat(errors.New(errConfigPathEmpty)) 128 | } 129 | 130 | _, err = os.Stat(cPath) 131 | 132 | if err != nil { 133 | // Something went wrong trying to find the config file. 134 | // Let's see if we can figure out what happened 135 | if os.IsNotExist(err) { 136 | // Ah, the config file does not exist, which is probably fine 137 | _, err = os.Create(cPath) 138 | if err != nil { 139 | // That was not fine 140 | Defeat(errors.New(errConfigFileCreate)) 141 | } 142 | 143 | } else { 144 | // But wait, it's some kind of other error. What kind? 145 | // I dunno, but it's probably bad so die 146 | Defeat(err) 147 | } 148 | } 149 | 150 | // Let's double-check that the file's there now 151 | fileInfo, err := os.Stat(cPath) 152 | if err != nil { 153 | Defeat(errors.New(errConfigFileAssert)) 154 | } 155 | 156 | // Write the default config, but only if the file is empty. 157 | // Don't want to stop on any non-default values the user has written in there 158 | if fileInfo.Size() == 0 { 159 | if ioutil.WriteFile(cPath, []byte(defaultConfig), 0600) != nil { 160 | Defeat(errors.New(errConfigFileWrite)) 161 | } 162 | 163 | Progress(fmt.Sprintf("created %s", cPath)) 164 | } 165 | } 166 | 167 | // readConfigFile reads the contents of the config file and jams them 168 | // into the global config variable 169 | func readConfigFile() *config.Config { 170 | cPath, err := GetConfigFilePath() 171 | if err != nil { 172 | Defeat(err) 173 | } 174 | 175 | if cPath == "" { 176 | Defeat(errors.New(errConfigPathEmpty)) 177 | } 178 | 179 | cfg, err := config.ParseYamlFile(cPath) 180 | if err != nil { 181 | Defeat(err) 182 | } 183 | 184 | return cfg 185 | } 186 | -------------------------------------------------------------------------------- /src/logging.go: -------------------------------------------------------------------------------- 1 | package src 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | ) 8 | 9 | // LL is a go routine-safe implementation of Logger 10 | // (More globals! This is getting crazy) 11 | var LL *log.Logger 12 | 13 | // Defeat writes out an error message 14 | func Defeat(err error) { 15 | LL.Fatal(fmt.Sprintf("%s %s", Red("✘"), err.Error())) 16 | } 17 | 18 | // Info writes out an informative message 19 | func Info(msg string) { 20 | LL.Print(fmt.Sprintf("%s %s", Green("->"), msg)) 21 | } 22 | 23 | // Progress writes out a progress status message 24 | func Progress(msg string) { 25 | LL.Print(fmt.Sprintf("\t%s %s\n", Blue("->"), msg)) 26 | } 27 | 28 | // Victory writes out a victorious final message and then expires dramatically 29 | func Victory(msg string) { 30 | LL.Print(fmt.Sprintf("%s %s", Green("✓"), msg)) 31 | os.Exit(0) 32 | } 33 | -------------------------------------------------------------------------------- /src/page_elements.go: -------------------------------------------------------------------------------- 1 | package src 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func Footer() string { 9 | return fmt.Sprintf( 10 | "generated %s by til\n", 11 | time.Now().Format("2 Jan 2006 15:04:05"), 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/target_directory.go: -------------------------------------------------------------------------------- 1 | package src 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/olebedev/config" 10 | ) 11 | 12 | const ( 13 | errTargetDirCreate = "could not create the target directories" 14 | errTargetDirFlag = "multiple target directories defined, no -t value provided" 15 | errTargetDirUndefined = "target directory is undefined or misconfigured in config" 16 | ) 17 | 18 | // BuildTargetDirectory verifies that the target directory, as specified in 19 | // the config file, exists and contains a /docs folder for writing pages to. 20 | // If these directories don't exist, it tries to create them 21 | func BuildTargetDirectory() { 22 | tDir, err := GetTargetDir(GlobalConfig, "", true) 23 | if err != nil { 24 | log.Printf("BuildTargetDirectory got error: %v", err) 25 | return 26 | } 27 | 28 | if _, err := os.Stat(tDir); os.IsNotExist(err) { 29 | err := os.MkdirAll(tDir, os.ModePerm) 30 | if err != nil { 31 | Defeat(errors.New(errTargetDirCreate)) 32 | } 33 | } 34 | } 35 | 36 | // GetTargetDir returns the absolute string path to the directory that the 37 | // content will be written to 38 | func GetTargetDir(cfg *config.Config, targetDirFlag string, withDocsDir bool) (string, error) { 39 | docsBit := "" 40 | if withDocsDir { 41 | docsBit = "/docs" 42 | } 43 | 44 | // Target directories are defined in the config file as a map of 45 | // identifier : target directory 46 | // Example: 47 | // targetDirectories: 48 | // a: ~/Documents/blog 49 | // b: ~/Documents/notes 50 | uDirs, err := cfg.Map("targetDirectories") 51 | if err != nil { 52 | return "", err 53 | } 54 | 55 | // config returns a map of [string]interface{} which is helpful on the 56 | // left side, not so much on the right side. Convert the right to strings 57 | tDirs := make(map[string]string, len(uDirs)) 58 | for k, dir := range uDirs { 59 | tDirs[k] = dir.(string) 60 | } 61 | 62 | // Extracts the dir we want operate against by using the value of the 63 | // -target flag passed in. If no value was passed in, AND we only have one 64 | // entry in the map, use that entry. If no value was passed in and there 65 | // are multiple entries in the map, raise an error because ¯\_(ツ)_/¯ 66 | tDir := "" 67 | 68 | if len(tDirs) == 1 { 69 | for _, dir := range tDirs { 70 | tDir = dir 71 | } 72 | } else { 73 | if targetDirFlag == "" { 74 | return "", errors.New(errTargetDirFlag) 75 | } 76 | 77 | tDir = tDirs[targetDirFlag] 78 | } 79 | 80 | if tDir == "" { 81 | return "", errors.New(errTargetDirUndefined) 82 | } 83 | 84 | // If we're not using a path relative to the user's home directory, 85 | // take the config value as a fully-qualified path and just append the 86 | // name of the write dir to it 87 | if tDir[0] != '~' { 88 | return tDir + docsBit, nil 89 | } 90 | 91 | // We are pathing relative to the home directory, so figure out the 92 | // absolute path for that 93 | dir, err := os.UserHomeDir() 94 | if err != nil { 95 | return "", errors.New(errConfigExpandPath) 96 | } 97 | 98 | return filepath.Join(dir, tDir[1:], docsBit), nil 99 | } 100 | -------------------------------------------------------------------------------- /til_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/olebedev/config" 10 | "github.com/senorprogrammer/til/pages" 11 | "github.com/senorprogrammer/til/src" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func Test_determineCommitMessage(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | cfgMessage string 19 | args []string 20 | expected string 21 | }{ 22 | { 23 | name: "passed in via -s", 24 | cfgMessage: "from the config", 25 | args: []string{"test", "-t", "b", "-s", "this", "is", "test"}, 26 | expected: "this is test", 27 | }, 28 | { 29 | name: "from config file", 30 | cfgMessage: "from the config", 31 | args: []string{"test", "-t", "b", "-s"}, 32 | expected: "from the config", 33 | }, 34 | { 35 | name: "from default const", 36 | cfgMessage: "", 37 | args: []string{"test", "-t", "b", "-s"}, 38 | expected: "build, save, push", 39 | }, 40 | } 41 | 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | os.Args = tt.args 45 | flag.Parse() 46 | 47 | msg := fmt.Sprintf("commitMessage: %s", tt.cfgMessage) 48 | cfg, _ := config.ParseYamlBytes([]byte(msg)) 49 | 50 | actual := determineCommitMessage(cfg, os.Args) 51 | 52 | assert.Equal(t, tt.expected, actual) 53 | }) 54 | } 55 | } 56 | 57 | /* -------------------- Configuration -------------------- */ 58 | 59 | func Test_getConfigPath(t *testing.T) { 60 | actual, err := src.GetConfigFilePath() 61 | 62 | assert.Contains(t, actual, "config.yml") 63 | assert.NoError(t, err) 64 | } 65 | 66 | /* -------------------- More Helper Functions -------------------- */ 67 | 68 | func Test_Colour(t *testing.T) { 69 | x := src.Colour("yo%soy") 70 | actual := x("cat") 71 | 72 | assert.Equal(t, "yocatoy", actual) 73 | } 74 | 75 | /* -------------------- Page -------------------- */ 76 | 77 | func Test_Page_CreatedAt(t *testing.T) { 78 | page := &pages.Page{Date: "2020-05-07T13:13:08-07:00"} 79 | 80 | actual := page.CreatedAt() 81 | 82 | assert.Equal(t, 2020, actual.Year()) 83 | assert.Equal(t, 5, int(actual.Month())) 84 | assert.Equal(t, 7, actual.Day()) 85 | } 86 | 87 | func Test_Page_CreatedMonth(t *testing.T) { 88 | page := &pages.Page{Date: "2020-05-07T13:13:08-07:00"} 89 | 90 | actual := page.CreatedMonth() 91 | 92 | assert.Equal(t, 5, int(actual)) 93 | } 94 | 95 | func Test_IsContentPage(t *testing.T) { 96 | tests := []struct { 97 | name string 98 | title string 99 | expected bool 100 | }{ 101 | { 102 | name: "when is not content page", 103 | title: "", 104 | expected: false, 105 | }, 106 | { 107 | name: "when is content page", 108 | title: "test", 109 | expected: true, 110 | }, 111 | } 112 | 113 | for _, tt := range tests { 114 | t.Run(tt.name, func(t *testing.T) { 115 | page := &pages.Page{Title: tt.title} 116 | 117 | actual := page.IsContentPage() 118 | 119 | assert.Equal(t, tt.expected, actual) 120 | }) 121 | } 122 | } 123 | 124 | func Test_Page_Link(t *testing.T) { 125 | page := &pages.Page{ 126 | Date: "2020-05-07T13:13:08-07:00", 127 | FilePath: "docs/zombies.md", 128 | Title: "Zombies", 129 | } 130 | 131 | actual := page.Link() 132 | 133 | assert.Equal(t, "May 07, 2020 [Zombies](zombies.md)", actual) 134 | } 135 | 136 | func Test_Page_PrettDate(t *testing.T) { 137 | page := &pages.Page{Date: "2020-05-07T13:13:08-07:00"} 138 | 139 | actual := page.PrettyDate() 140 | 141 | assert.Equal(t, "May 07, 2020", actual) 142 | } 143 | 144 | /* -------------------- Tag -------------------- */ 145 | 146 | func Test_Tag_NewTag(t *testing.T) { 147 | actual := pages.NewTag("ada", &pages.Page{Title: "test"}) 148 | 149 | assert.IsType(t, &pages.Tag{}, actual) 150 | assert.Equal(t, "ada", actual.Name) 151 | assert.Equal(t, "test", actual.Pages[0].Title) 152 | } 153 | 154 | func Test_Tag_AddPage(t *testing.T) { 155 | tag := pages.NewTag("ada", &pages.Page{Title: "test"}) 156 | tag.AddPage(&pages.Page{Title: "zombies"}) 157 | 158 | assert.Equal(t, 2, len(tag.Pages)) 159 | assert.Equal(t, "zombies", tag.Pages[1].Title) 160 | } 161 | 162 | func Test_Tag_IsValid(t *testing.T) { 163 | tests := []struct { 164 | name string 165 | title string 166 | expected bool 167 | }{ 168 | { 169 | name: "when invalid", 170 | title: "", 171 | expected: false, 172 | }, 173 | { 174 | name: "when valid", 175 | title: "test", 176 | expected: true, 177 | }, 178 | } 179 | 180 | for _, tt := range tests { 181 | t.Run(tt.name, func(t *testing.T) { 182 | tag := pages.NewTag(tt.title, &pages.Page{}) 183 | 184 | actual := tag.IsValid() 185 | 186 | assert.Equal(t, tt.expected, actual) 187 | }) 188 | } 189 | } 190 | 191 | /* -------------------- TagMap -------------------- */ 192 | 193 | func Test_NewTagMap(t *testing.T) { 194 | tests := []struct { 195 | name string 196 | pages []*pages.Page 197 | expectedLen int 198 | }{ 199 | { 200 | name: "with no pages", 201 | pages: []*pages.Page{}, 202 | expectedLen: 0, 203 | }, 204 | { 205 | name: "with pages", 206 | pages: []*pages.Page{ 207 | {TagsStr: "go, ada"}, 208 | }, 209 | expectedLen: 2, 210 | }, 211 | } 212 | 213 | for _, tt := range tests { 214 | t.Run(tt.name, func(t *testing.T) { 215 | actual := pages.NewTagMap(tt.pages).Tags 216 | 217 | assert.Equal(t, tt.expectedLen, len(actual)) 218 | }) 219 | } 220 | } 221 | 222 | func Test_TagMap_Add(t *testing.T) { 223 | tests := []struct { 224 | name string 225 | tag *pages.Tag 226 | expectedLen int 227 | }{ 228 | { 229 | name: "with an invalid tag", 230 | tag: &pages.Tag{}, 231 | expectedLen: 0, 232 | }, 233 | { 234 | name: "with a new tag", 235 | tag: &pages.Tag{Name: "go"}, 236 | expectedLen: 1, 237 | }, 238 | } 239 | 240 | for _, tt := range tests { 241 | t.Run(tt.name, func(t *testing.T) { 242 | tMap := pages.NewTagMap([]*pages.Page{}) 243 | tMap.Add(tt.tag) 244 | 245 | actual := tMap.Tags 246 | 247 | assert.Equal(t, tt.expectedLen, len(actual)) 248 | }) 249 | } 250 | } 251 | 252 | func Test_TagMap_BuildFromPages(t *testing.T) { 253 | tests := []struct { 254 | name string 255 | pages []*pages.Page 256 | expectedLen int 257 | }{ 258 | { 259 | name: "with no pages", 260 | pages: []*pages.Page{}, 261 | expectedLen: 0, 262 | }, 263 | { 264 | name: "with pages", 265 | pages: []*pages.Page{ 266 | {TagsStr: "go"}, 267 | {TagsStr: "ada"}, 268 | }, 269 | expectedLen: 2, 270 | }, 271 | } 272 | 273 | for _, tt := range tests { 274 | t.Run(tt.name, func(t *testing.T) { 275 | tMap := pages.NewTagMap([]*pages.Page{}) 276 | tMap.BuildFromPages(tt.pages) 277 | 278 | actual := tMap.Tags 279 | 280 | assert.Equal(t, tt.expectedLen, len(actual)) 281 | }) 282 | } 283 | } 284 | 285 | func Test_TagMap_Get(t *testing.T) { 286 | tests := []struct { 287 | name string 288 | input string 289 | expectedLen int 290 | }{ 291 | { 292 | name: "with missing tag", 293 | input: "ada", 294 | expectedLen: 0, 295 | }, 296 | { 297 | name: "with valid tag", 298 | input: "go", 299 | expectedLen: 1, 300 | }, 301 | } 302 | 303 | for _, tt := range tests { 304 | pageSet := []*pages.Page{{TagsStr: "go"}} 305 | tMap := pages.NewTagMap(pageSet) 306 | 307 | actual := tMap.Get(tt.input) 308 | 309 | t.Run(tt.name, func(t *testing.T) { 310 | assert.Equal(t, tt.expectedLen, len(actual)) 311 | }) 312 | } 313 | } 314 | 315 | func Test_TagMap_Len(t *testing.T) { 316 | tests := []struct { 317 | name string 318 | page *pages.Page 319 | expectedLen int 320 | }{ 321 | { 322 | name: "with missing tag", 323 | page: &pages.Page{}, 324 | expectedLen: 0, 325 | }, 326 | { 327 | name: "with valid tag", 328 | page: &pages.Page{TagsStr: "go"}, 329 | expectedLen: 1, 330 | }, 331 | } 332 | 333 | for _, tt := range tests { 334 | pageSet := []*pages.Page{tt.page} 335 | tMap := pages.NewTagMap(pageSet) 336 | 337 | actual := tMap.Len() 338 | 339 | t.Run(tt.name, func(t *testing.T) { 340 | assert.Equal(t, tt.expectedLen, actual) 341 | }) 342 | } 343 | } 344 | 345 | func Test_TagMap_SortedTagNames(t *testing.T) { 346 | pageSet := []*pages.Page{{TagsStr: "go, ada, lua"}} 347 | tMap := pages.NewTagMap(pageSet) 348 | 349 | expected := []string{"ada", "go", "lua"} 350 | actual := tMap.SortedTagNames() 351 | 352 | assert.Equal(t, expected, actual) 353 | } 354 | --------------------------------------------------------------------------------