├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cli ├── build.go ├── cli.go ├── create.go ├── sample │ ├── posts │ │ ├── creating-a-blog.md │ │ ├── creating-posts.md │ │ ├── generating-html-files-for-a-blog-aka-building-a-blog.md │ │ ├── getting-help.md │ │ ├── installation.md │ │ ├── overview.md │ │ ├── quick-start.md │ │ ├── serving-a-blog.md │ │ └── templates.md │ └── templates │ │ ├── css │ │ ├── font.min.css │ │ ├── main.min.css │ │ ├── normalize.min.css │ │ ├── prism.min.css │ │ └── skeleton.min.css │ │ ├── favicon.ico │ │ ├── images │ │ ├── email-16.png │ │ ├── email-24.png │ │ ├── github-16.png │ │ ├── github-24.png │ │ ├── logo.png │ │ ├── twitter-16.png │ │ └── twitter-24.png │ │ ├── index.tmpl │ │ ├── js │ │ └── prism.min.js │ │ ├── layout.tmpl │ │ ├── post.tmpl │ │ └── tag.tmpl ├── serve.go └── usage.go ├── go.mod ├── go.sum ├── lib ├── blog.go ├── html.go ├── markdown.go └── markdown_test.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | _dist 3 | ROADMAP.md 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Miro Varga [mirovarga.com] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LINUX_OS = linux 2 | WIN_OS = windows 3 | DARWIN_OS = darwin 4 | ARCH_AMD = amd64 5 | ARCH_ARM = arm64 6 | 7 | DIST_DIR = _dist 8 | LINUX_DIR = $(DIST_DIR)/$(LINUX_OS) 9 | WIN_DIR = $(DIST_DIR)/$(WIN_OS) 10 | DARWIN_DIR = $(DIST_DIR)/$(DARWIN_OS) 11 | 12 | BIN_FILE = litepub 13 | OTHER_FILES = LICENSE README.md 14 | 15 | VERSION = $(shell git describe --tags --abbrev=0) 16 | 17 | build: clean 18 | @go build 19 | 20 | install: clean 21 | @go install 22 | 23 | dist: clean 24 | @echo "Building Linux distribution" 25 | @mkdir -p $(LINUX_DIR) 26 | @GOOS=$(LINUX_OS) GOARCH=$(ARCH_AMD) go build -o $(LINUX_DIR)/$(BIN_FILE) 27 | @zip -qj9 $(DIST_DIR)/$(BIN_FILE)-$(VERSION)-$(LINUX_OS)-$(ARCH_AMD).zip $(LINUX_DIR)/$(BIN_FILE) $(OTHER_FILES) 28 | @rm -rf $(LINUX_DIR) 29 | 30 | @echo "Building Windows distribution" 31 | @mkdir -p $(WIN_DIR) 32 | @GOOS=$(WIN_OS) GOARCH=$(ARCH_AMD) go build -o $(WIN_DIR)/$(BIN_FILE).exe 33 | @zip -qj9 $(DIST_DIR)/$(BIN_FILE)-$(VERSION)-$(WIN_OS)-$(ARCH_AMD).zip $(WIN_DIR)/$(BIN_FILE).exe $(OTHER_FILES) 34 | @rm -rf $(WIN_DIR) 35 | 36 | @echo "Building Darwin distribution" 37 | @mkdir -p $(DARWIN_DIR) 38 | @GOOS=$(DARWIN_OS) GOARCH=$(ARCH_ARM) go build -o $(DARWIN_DIR)/$(BIN_FILE) 39 | @zip -qj9 $(DIST_DIR)/$(BIN_FILE)-$(VERSION)-$(DARWIN_OS)-$(ARCH_ARM).zip $(DARWIN_DIR)/$(BIN_FILE) $(OTHER_FILES) 40 | @rm -rf $(DARWIN_DIR) 41 | 42 | clean: 43 | @go clean 44 | @rm -rf $(DIST_DIR) 45 | 46 | .PHONY: build install dist clean 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LitePub 2 | 3 | A lightweight static blog generator written in Go. 4 | 5 | > Why another one? I wrote a blog post that briefly describes 6 | [why I created it](http://www.mirovarga.com/a-lightweight-static-blog-generator-in-go.html). 7 | 8 | ## Overview 9 | 10 | LitePub is a static blog generator that tries to be as easy to use as possible. 11 | 12 | It requires no software dependencies, needs no configuration files, uses no 13 | databases. All it needs is one binary, posts written in 14 | [Markdown](https://en.wikipedia.org/wiki/Markdown) and a set of templates to 15 | render the posts to static HTML files. 16 | 17 | Posts don't have to include any special metadata (aka front matter) like title 18 | or date in them - the title, date and optional tags are parsed from the natural 19 | flow of the posts. 20 | 21 | ## Quick Start 22 | 23 | To create a sample blog follow these steps: 24 | 25 | 1. Download a [release](https://github.com/mirovarga/litepub/releases) and 26 | unpack it to a directory. 27 | 28 | 2. `cd` to the directory. 29 | 30 | 3. Create a sample blog: 31 | 32 | ```shell 33 | ./litepub create 34 | ``` 35 | 36 | 4. Build the blog: 37 | 38 | ```shell 39 | ./litepub build 40 | Generating: index.html 41 | Generating: tags/reference.html 42 | Generating: tags/tutorial.html 43 | Generating: tags/advanced.html 44 | Generating: tags/docs.html 45 | Generating: tags/basics.html 46 | Generating: overview.html 47 | Generating: quick-start.html 48 | Generating: installation.html 49 | Generating: creating-a-blog.html 50 | Generating: creating-posts.html 51 | Generating: generating-html-files-for-a-blog-aka-building-a-blog.html 52 | Generating: serving-a-blog.html 53 | Generating: templates.html 54 | Generating: getting-help.html 55 | ``` 56 | 57 | 5. Run the built-in server: 58 | 59 | ```shell 60 | ./litepub serve 61 | Running on http://localhost:2703 62 | Ctrl+C to quit 63 | ``` 64 | 65 | 6. Open [http://localhost:2703](http://localhost:2703) in your browser. 66 | 67 | ## Documentation 68 | 69 | ### Installation 70 | 71 | #### Via the `go install` command 72 | 73 | ```shell 74 | go install github.com/mirovarga/litepub@latest 75 | ``` 76 | 77 | #### Manually 78 | 79 | Download a [release](https://github.com/mirovarga/litepub/releases) and unpack 80 | it to a directory. 81 | 82 | > You can optionally add the directory to the `PATH` so you can run `litepub` 83 | from any directory. All examples assume you have `litepub` in your `PATH`. 84 | 85 | ### Creating a Blog 86 | 87 | The following will create a sample blog in the current directory: 88 | 89 | ```shell 90 | litepub create 91 | ``` 92 | 93 | If you don't need the sample templates and posts use the `--skeleton` option: 94 | 95 | ```shell 96 | litepub create --skeleton 97 | ``` 98 | 99 | > Because the template files are required they will be still created but with 100 | > no content. 101 | 102 | #### Directory Structure 103 | 104 | Each blog is stored in a directory with the following structure: 105 | 106 | ```shell 107 | posts/ # the posts 108 | draft/ # the draft posts (they are ignored when building the blog) 109 | templates/ # the templates and accompanying files (html, css, js, png, etc.) 110 | layout.tmpl 111 | index.tmpl 112 | post.tmpl 113 | tag.tmpl 114 | www/ # the generated HTML files (plus copied accompanying files) 115 | ``` 116 | 117 | #### The **create** Command Reference 118 | 119 | ``` 120 | Usage: 121 | litepub create [] [-s, --skeleton] [-q, --quiet] 122 | 123 | Arguments: 124 | The directory to create the blog in or look for; it will be created if 125 | it doesn't exist (only when creating a blog) [default: .] 126 | 127 | Options: 128 | -s, --skeleton Don't create sample posts and templates 129 | -q, --quiet Show only errors 130 | ``` 131 | 132 | ### Creating Posts 133 | 134 | To create a post just add a [Markdown](https://en.wikipedia.org/wiki/Markdown) 135 | file in the `posts` directory. The file name and extension aren't important, 136 | only the content of the file. 137 | 138 | > All posts need to be stored directly in the `posts` directory. In other words, 139 | > subdirectories in the `posts` directory are ignored when looking for posts. 140 | 141 | Each post looks like this (it's the start of an 142 | [actual post](http://www.mirovarga.com/how-i-switched-from-java-to-javascript.html) 143 | from my blog): 144 | 145 | ```markdown 146 | 1 # How I Switched from Java to JavaScript 147 | 2 148 | 3 *Jan 25, 2015* 149 | 4 150 | 5 *Java, JavaScript* 151 | 6 152 | 7 I know that there are lots of posts about why JavaScript, or more specifically 153 | 8 Node.js, is better than Java but nevertheless I wanted to contribute, too. 154 | 9 ... 155 | ``` 156 | 157 | - Line `1` is the post's title. If it starts with one or more `#`s they are 158 | stripped. So in this case the title becomes *How I Switched from Java to 159 | JavaScript*. 160 | - Line `3` is the post's date. It has to be in the `*MMM d, YYYY*` format. 161 | - Line `5` are comma separated post tags. 162 | - Anything below line `6` is the content of the post. 163 | 164 | > The post's title and date are required. Tags are optional. 165 | 166 | #### Draft Posts 167 | 168 | Any post can be marked as draft by simply moving it to the `draft` subdirectory 169 | of the `posts` directory. To unmark it just move it back to the `posts` 170 | directory. 171 | 172 | > Deleting a post is analogous to drafting: just remove it from the `posts` 173 | directory. 174 | 175 | ### Generating HTML Files for a Blog, aka Building a Blog 176 | 177 | To generate the HTML files for a blog `cd` to the blog's directory and use the 178 | `build` command: 179 | 180 | ```shell 181 | litepub build 182 | Generating: index.html 183 | Generating: tags/reference.html 184 | Generating: tags/tutorial.html 185 | Generating: tags/advanced.html 186 | Generating: tags/docs.html 187 | Generating: tags/basics.html 188 | Generating: overview.html 189 | Generating: quick-start.html 190 | Generating: installation.html 191 | Generating: creating-a-blog.html 192 | Generating: creating-posts.html 193 | Generating: generating-html-files-for-a-blog-aka-building-a-blog.html 194 | Generating: serving-a-blog.html 195 | Generating: templates.html 196 | Generating: getting-help.html 197 | ``` 198 | 199 | > The draft posts and posts starting with a dot (`.`) are ignored when building 200 | > a blog. 201 | 202 | LitePub takes the `*.tmpl` files from the `templates` directory, applies them to 203 | posts stored in the `posts` directory and generates the HTML files to the `www` 204 | directory. It also copies all accompanying files (and directories) from 205 | the `templates` directory to the `www` directory. 206 | 207 | > The generated HTML file names are created by slugifying the post title 208 | > (or the tag name when generating tag pages) and adding the `html` extension. 209 | > For example, a post with the *How I Switched from Java to JavaScript* title is 210 | > generated to the `how-i-switched-from-java-to-javascript.html` file. 211 | 212 | #### The **build** Command Reference 213 | 214 | ``` 215 | Usage: 216 | litepub build [] [-q, --quiet] 217 | 218 | Arguments: 219 | The directory to create the blog in or look for; it will be created if 220 | it doesn't exist (only when creating a blog) [default: .] 221 | 222 | Options: 223 | -q, --quiet Show only errors 224 | ``` 225 | 226 | ### Serving a Blog 227 | 228 | LitePub has a built-in server so you can see how a generated blog looks like in 229 | a browser. `cd` to the blog's directory and start the server: 230 | 231 | ```shell 232 | litepub serve 233 | Running on http://localhost:2703 234 | Ctrl+C to quit 235 | ``` 236 | 237 | Now open [http://localhost:2703](http://localhost:2703) in your browser to see 238 | the generated blog. 239 | 240 | #### Serving a Blog on a Different Port 241 | 242 | When starting the server you can specify a port on which to listen with the 243 | `--port` option: 244 | 245 | ```shell 246 | litepub serve --port 3000 247 | Running on http://localhost:3000 248 | Ctrl+C to quit 249 | ``` 250 | 251 | #### Serving a Blog and Watching for Changes 252 | 253 | When creating templates or even writing posts it's quite useful to be able to 254 | immediately see the changes after refreshing the page. To tell LitePub that it 255 | should watch for changes to posts and templates use the `--watch` option: 256 | 257 | ```shell 258 | litepub serve --watch 259 | Running on http://localhost:2703 260 | Rebuilding when posts or templates change 261 | Ctrl+C to quit 262 | ``` 263 | 264 | > Note that subdirectories in the `posts` and `templates` directories aren't 265 | > watched. 266 | 267 | #### Rebuilding a Blog Before Serving 268 | 269 | Sometimes it can be useful to rebuild a blog before serving it, for example when 270 | you don't remember if you made any changes to posts or templates. To rebuild a 271 | blog before serving use the `--rebuild` option: 272 | 273 | ```shell 274 | litepub serve --rebuild 275 | Generating: index.html 276 | Generating: tags/reference.html 277 | Generating: tags/tutorial.html 278 | Generating: tags/advanced.html 279 | Generating: tags/docs.html 280 | Generating: tags/basics.html 281 | Generating: overview.html 282 | Generating: quick-start.html 283 | Generating: installation.html 284 | Generating: creating-a-blog.html 285 | Generating: creating-posts.html 286 | Generating: generating-html-files-for-a-blog-aka-building-a-blog.html 287 | Generating: serving-a-blog.html 288 | Generating: templates.html 289 | Generating: getting-help.html 290 | Running on http://localhost:2703 291 | Ctrl+C to quit 292 | ``` 293 | 294 | #### The **serve** Command Reference 295 | 296 | ``` 297 | Usage: 298 | litepub serve [] [-R, --rebuild] [-p, --port ] [-w, --watch] [-q, --quiet] 299 | 300 | Arguments: 301 | The directory to create the blog in or look for; it will be created if 302 | it doesn't exist (only when creating a blog) [default: .] 303 | 304 | Options: 305 | -R, --rebuild Rebuild the blog before serving 306 | -p, --port The port to listen on [default: 2703] 307 | -w, --watch Rebuild the blog when posts or templates change 308 | -q, --quiet Show only errors 309 | ``` 310 | 311 | ### Templates 312 | 313 | The `create` command adds sample templates to the `templates` directory. Of 314 | course, you can change them or create your own from scratch. 315 | 316 | LitePub uses the Go [html/template](https://golang.org/pkg/html/template/) 317 | package to define the templates. 318 | 319 | > Design changes require no knowledge of Go templates. However changes that 320 | > affect what data is displayed will require it less or more (depending on 321 | > the change). 322 | 323 | #### Structure 324 | 325 | There are four required files in the `templates` directory: 326 | 327 | ```shell 328 | templates/ # the templates and accompanying files (html, css, js, png, etc.) 329 | layout.tmpl 330 | index.tmpl 331 | post.tmpl 332 | tag.tmpl 333 | ``` 334 | 335 | - `layout.tmpl` defines the common layout for the home page (`index.tmpl`), post 336 | pages (`post.tmpl`) and tag pages (`tag.tmpl`) 337 | - `index.tmpl` is used when generating the home page (`index.html`) 338 | - `post.tmpl` is used when generating post pages 339 | - and `tag.tmpl` is used when generating tag pages 340 | 341 | Besides the four files there can be any number of `html`, `css`, `js`, `png`, 342 | etc. files that are used by the `.tmpl` files. 343 | 344 | > If you're not familiar with Go templates, some things in the next sections can 345 | > be unclear. 346 | 347 | #### Data 348 | 349 | Templates have access to data they are meant to display. There are two types of 350 | data: `Post`s and `Tag`s. 351 | 352 | ##### Posts 353 | 354 | A `Post` has the following properties: 355 | 356 | - `Title` - the post title 357 | - `Content` - the content of the post as Markdown text 358 | - `Written` - the post's date 359 | - `Tags` - an array of tags the post is tagged with (can be empty) 360 | - `Draft` - `true` if the post is a draft 361 | 362 | > To get a post's page URL in a template use the `slug` function (described 363 | > below) like this: `A Post`. 364 | 365 | ##### Tags 366 | 367 | A `Tag` has the following properties: 368 | 369 | - `Name` - the tag name 370 | - `Posts` - an array of `Post`s that are tagged with the tag sorted by `Written` 371 | in descending order 372 | 373 | > To get a tag's page URL in a template use the `slug` function (described 374 | > below) like this: `A Tag`. 375 | 376 | The `index.tmpl` template has access to an array of `Post`s sorted by `Written` 377 | in descending order. The `post.tmpl` template has access to the `Post` it 378 | displays. The `tag.tmpl` template has access to the `Tag` it displays. 379 | 380 | #### Functions 381 | 382 | The `index.tmpl`, `post.tmpl` and `tag.tmpl` templates have access to the 383 | following functions: 384 | 385 | ##### html 386 | 387 | Converts a Markdown string to a raw HTML, for example `{{.Content | html}}`. 388 | 389 | ##### summary 390 | 391 | Extracts the first paragraph of a Markdown string that isn't a header (doesn't 392 | start with a `#`), for example `{{.Content | summary | html}}`. 393 | 394 | ##### even 395 | 396 | Returns `true` if an integer is even, for example 397 | `{{if even $i}}{{end}}`. 398 | 399 | ##### inc 400 | 401 | Increments an integer by one, for example 402 | `{{if or (eq (inc $i) $l) (not (even $i))}}{{end}}`. 403 | 404 | ##### slug 405 | 406 | Slugifies a string, for example `A Post`. 407 | 408 | > The available functions represent my needs when converting my handmade blog 409 | > to a generated one. 410 | 411 | ### Getting Help 412 | 413 | To see all available commands and their options use the `--help` option: 414 | 415 | ```shell 416 | litepub --help 417 | LitePub 0.5.7 [github.com/mirovarga/litepub] 418 | Copyright (c) 2024 Miro Varga [mirovarga.com] 419 | 420 | Usage: 421 | litepub create [] [-s, --skeleton] [-q, --quiet] 422 | litepub build [] [-q, --quiet] 423 | litepub serve [] [-R, --rebuild] [-p, --port ] [-w, --watch] [-q, --quiet] 424 | 425 | Arguments: 426 | The directory to create the blog in or look for; it will be created if 427 | it doesn't exist (only when creating a blog) [default: .] 428 | 429 | Options: 430 | -s, --skeleton Don't create sample posts and templates 431 | -R, --rebuild Rebuild the blog before serving 432 | -p, --port The port to listen on [default: 2703] 433 | -w, --watch Rebuild the blog when posts or templates change 434 | -q, --quiet Show only errors 435 | -h, --help Show this screen 436 | -v, --version Show version 437 | ``` 438 | -------------------------------------------------------------------------------- /cli/build.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/mirovarga/litepub/lib" 7 | ) 8 | 9 | func build(arguments map[string]interface{}) int { 10 | dir := arguments[""].(string) 11 | 12 | blog, err := lib.NewMarkdownBlog(dir).Read() 13 | if err != nil { 14 | log.Errorf("Failed to read blog: %s\n", err) 15 | return 1 16 | } 17 | 18 | gen, err := lib.NewStaticBlogGenerator(blog, filepath.Join(dir, templatesDir), 19 | filepath.Join(dir, outputDir), printProgress) 20 | if err != nil { 21 | log.Errorf("Failed to create generator: %s\n", err) 22 | return 1 23 | } 24 | 25 | err = gen.Generate() 26 | if err != nil { 27 | log.Errorf("Failed to generate blog: %s\n", err) 28 | return 1 29 | } 30 | 31 | return 0 32 | } 33 | 34 | func printProgress(path string) { 35 | log.Infof("Generating: %s\n", path) 36 | } 37 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/docopt/docopt-go" 7 | ) 8 | 9 | const ( 10 | postsDir = "posts" 11 | templatesDir = "templates" 12 | outputDir = "www" 13 | ) 14 | 15 | var log quietableLog 16 | 17 | // TODO pass args (also check for other ways to decouple things) 18 | func Run() int { 19 | arguments, _ := docopt.ParseArgs(usage, nil, "LitePub 0.5.7") 20 | 21 | log = quietableLog{arguments["--quiet"].(int) == 1} 22 | 23 | if _, ok := arguments[""].(string); !ok { 24 | arguments[""] = "." 25 | } 26 | 27 | if arguments["create"].(bool) { 28 | return create(arguments) 29 | } else if arguments["build"].(bool) { 30 | return build(arguments) 31 | } else if arguments["serve"].(bool) { 32 | return serve(arguments) 33 | } 34 | 35 | return 0 36 | } 37 | 38 | type quietableLog struct { 39 | quiet bool 40 | } 41 | 42 | func (l quietableLog) Infof(format string, v ...interface{}) { 43 | if !l.quiet { 44 | fmt.Printf(format, v...) 45 | } 46 | } 47 | 48 | func (l quietableLog) Errorf(format string, v ...interface{}) { 49 | fmt.Printf("ERROR: "+format, v...) 50 | } 51 | -------------------------------------------------------------------------------- /cli/create.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/mirovarga/litepub/lib" 11 | ) 12 | 13 | //go:embed sample 14 | var assets embed.FS 15 | 16 | var templates = []string{"layout.tmpl", "index.tmpl", "post.tmpl", "tag.tmpl"} 17 | 18 | func create(arguments map[string]interface{}) int { 19 | dir := arguments[""].(string) 20 | 21 | lib.NewMarkdownBlog(dir) 22 | 23 | if arguments["--skeleton"].(int) == 1 { 24 | // TODO here we should call something like lib.NewStaticBlogGenerator(dir) 25 | // with this branch's functionality and execute the else branch when 26 | // --skeleton == 0 27 | tmplDir := filepath.Join(dir, templatesDir) 28 | err := os.MkdirAll(tmplDir, 0700) 29 | if err != nil { 30 | log.Errorf("Failed to create blog: %s\n", err) 31 | return 1 32 | } 33 | 34 | for _, template := range templates { 35 | err := os.WriteFile(filepath.Join(tmplDir, template), nil, 0600) 36 | if err != nil { 37 | log.Errorf("Failed to create blog: %s\n", err) 38 | return 1 39 | } 40 | } 41 | } else { 42 | err := fs.WalkDir(assets, "sample", func(path string, d fs.DirEntry, err error) error { 43 | if path == "sample" { 44 | return nil 45 | } 46 | 47 | trimmedPath := strings.TrimPrefix(path, "sample/") 48 | if d.IsDir() { 49 | return os.MkdirAll(filepath.Join(dir, trimmedPath), 0700) 50 | } else { 51 | bytes, err := fs.ReadFile(assets, path) 52 | if err != nil { 53 | return err 54 | } 55 | return os.WriteFile(filepath.Join(dir, trimmedPath), bytes, 0600) 56 | } 57 | }) 58 | if err != nil { 59 | log.Errorf("Failed to create blog: %s\n", err) 60 | return 1 61 | } 62 | } 63 | 64 | log.Infof("Created blog: %s\n", dir) 65 | return 0 66 | } 67 | -------------------------------------------------------------------------------- /cli/sample/posts/creating-a-blog.md: -------------------------------------------------------------------------------- 1 | # Creating a Blog 2 | 3 | *Nov 16, 2015* 4 | 5 | *Docs, Basics* 6 | 7 | The following will create a sample blog in the current directory: 8 | 9 | ``` 10 | $ litepub create 11 | ``` 12 | 13 | If you don't need the sample templates and posts use the `--skeleton` option: 14 | 15 | ``` 16 | $ litepub create --skeleton 17 | ``` 18 | 19 | > Because the template files are required they will be still created but with no content. 20 | 21 | ## Directory Structure 22 | 23 | Each blog is stored in a directory with the following structure: 24 | 25 | ```bash 26 | posts/ # the posts 27 | draft/ # the draft posts (they are ignored when building the blog) 28 | templates/ # the templates and accompanying files (html, css, js, png, etc.) 29 | layout.tmpl 30 | index.tmpl 31 | post.tmpl 32 | tag.tmpl 33 | www/ # the generated HTML files (plus copied accompanying files) 34 | ``` 35 | 36 | ## The **create** Command Reference 37 | 38 | ``` 39 | Usage: 40 | litepub create [] [-s, --skeleton] [-q, --quiet] 41 | 42 | Arguments: 43 | The directory to create the blog in or look for; it will be created if 44 | it doesn't exist (only when creating a blog) [default: .] 45 | 46 | Options: 47 | -s, --skeleton Don't create sample posts and templates 48 | -q, --quiet Show only errors 49 | ``` 50 | 51 | **Next**: [Creating Posts](/creating-posts.html) 52 | -------------------------------------------------------------------------------- /cli/sample/posts/creating-posts.md: -------------------------------------------------------------------------------- 1 | # Creating Posts 2 | 3 | *Nov 15, 2015* 4 | 5 | *Docs, Basics* 6 | 7 | To create a post just add a [Markdown](https://en.wikipedia.org/wiki/Markdown) 8 | file in the `posts` directory. The file name and extension aren't important, 9 | only the content of the file. 10 | 11 | > All posts need to be stored directly in the `posts` directory. In other words, subdirectories in the `posts` directory are ignored when looking for posts. 12 | 13 | Each post looks like this (it's the start of an 14 | [actual post](http://www.mirovarga.com/how-i-switched-from-java-to-javascript.html) 15 | from my blog): 16 | 17 | ```markdown 18 | 1 # How I Switched from Java to JavaScript 19 | 2 20 | 3 *Jan 25, 2015* 21 | 4 22 | 5 *Java, JavaScript* 23 | 6 24 | 7 I know that there are lots of posts about why JavaScript, or more specifically 25 | 8 Node.js, is better than Java but nevertheless I wanted to contribute, too. 26 | 9 ... 27 | ``` 28 | 29 | - Line `1` is the post's title. If it starts with one or more `#`s they are 30 | stripped. So in this case the title becomes *How I Switched from Java to 31 | JavaScript*. 32 | - Line `3` is the post's date. It has to be in the `*MMM d, YYYY*` format. 33 | - Line `5` are comma separated post tags. 34 | - Anything below line `6` is the content of the post. 35 | 36 | > The post's title and date are required. Tags are optional. 37 | 38 | ## Draft Posts 39 | 40 | Any post can be marked as draft by simply moving it to the `draft` subdirectory 41 | of the `posts` directory. To unmark it just move it back to the `posts` 42 | directory. 43 | 44 | > Deleting a post is analogous to drafting: just remove it from the `posts` 45 | directory. 46 | 47 | ** 48 | Next**: [Generating HTML Files for a Blog, aka Building a Blog](/generating-html-files-for-a-blog-aka-building-a-blog.html) 49 | -------------------------------------------------------------------------------- /cli/sample/posts/generating-html-files-for-a-blog-aka-building-a-blog.md: -------------------------------------------------------------------------------- 1 | # Generating HTML Files for a Blog, aka Building a Blog 2 | 3 | *Nov 14, 2015* 4 | 5 | *Docs, Basics* 6 | 7 | To generate the HTML files for a blog `cd` to the blog's directory and use the 8 | `build` command: 9 | 10 | ``` 11 | $ litepub build 12 | Generating: index.html 13 | Generating: tags/reference.html 14 | Generating: tags/tutorial.html 15 | Generating: tags/advanced.html 16 | Generating: tags/docs.html 17 | Generating: tags/basics.html 18 | Generating: overview.html 19 | Generating: quick-start.html 20 | Generating: installation.html 21 | Generating: creating-a-blog.html 22 | Generating: creating-posts.html 23 | Generating: generating-html-files-for-a-blog-aka-building-a-blog.html 24 | Generating: serving-a-blog.html 25 | Generating: templates.html 26 | Generating: getting-help.html 27 | ``` 28 | 29 | > The draft posts are ignored when building a blog. 30 | 31 | LitePub takes the `*.tmpl` files from the `templates` directory, applies them to 32 | posts stored in the `posts` directory and generates the HTML files to the `www` 33 | directory. It also copies all accompanying files (and directories) from 34 | the `templates` directory to the `www` directory. 35 | 36 | > The generated HTML file names are created by slugifying the post title (or the tag name when generating tag pages) and adding the `html` extension. For example, a post with the *How I Switched from Java to JavaScript* title is generated to the `how-i-switched-from-java-to-javascript.html` file. 37 | 38 | ## The **build** Command Reference 39 | 40 | ``` 41 | Usage: 42 | litepub build [] [-q, --quiet] 43 | 44 | Arguments: 45 | The directory to create the blog in or look for; it will be created if 46 | it doesn't exist (only when creating a blog) [default: .] 47 | 48 | Options: 49 | -q, --quiet Show only errors 50 | ``` 51 | 52 | **Next**: [Serving a Blog](/serving-a-blog.html) 53 | -------------------------------------------------------------------------------- /cli/sample/posts/getting-help.md: -------------------------------------------------------------------------------- 1 | # Getting Help 2 | 3 | *Nov 11, 2015* 4 | 5 | *Docs, Reference* 6 | 7 | To see all available commands and their options use the `--help` option: 8 | 9 | ``` 10 | $ litepub --help 11 | LitePub 0.5.7 [github.com/mirovarga/litepub] 12 | Copyright (c) 2024 Miro Varga [mirovarga.com] 13 | 14 | Usage: 15 | litepub create [] [-s, --skeleton] [-q, --quiet] 16 | litepub build [] [-q, --quiet] 17 | litepub serve [] [-R, --rebuild] [-p, --port ] [-w, --watch] [-q, --quiet] 18 | 19 | Arguments: 20 | The directory to create the blog in or look for; it will be created if 21 | it doesn't exist (only when creating a blog) [default: .] 22 | 23 | Options: 24 | -s, --skeleton Don't create sample posts and templates 25 | -R, --rebuild Rebuild the blog before serving 26 | -p, --port The port to listen on [default: 2703] 27 | -w, --watch Rebuild the blog when posts or templates change 28 | -q, --quiet Show only errors 29 | -h, --help Show this screen 30 | -v, --version Show version 31 | ``` 32 | -------------------------------------------------------------------------------- /cli/sample/posts/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | *Nov 17, 2015* 4 | 5 | *Docs, Basics* 6 | 7 | Download a [release](https://github.com/mirovarga/litepub/releases) and unpack 8 | it to a directory. That's all. 9 | 10 | > You can optionally add the directory to the `PATH` so you can run `litepub` 11 | from any directory. All examples assume you have `litepub` in your `PATH`. 12 | 13 | **Next**: [Creating a Blog](/creating-a-blog.html) 14 | -------------------------------------------------------------------------------- /cli/sample/posts/overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | *Nov 19, 2015* 4 | 5 | LitePub is a static blog generator that tries to be as easy to use as possible. 6 | 7 | It requires no software dependencies, needs no configuration files, uses no 8 | databases. All it needs is one binary, posts written in 9 | [Markdown](https://en.wikipedia.org/wiki/Markdown) and a set of templates to 10 | render the posts to static HTML files. 11 | 12 | Posts don't have to include any special metadata (aka front matter) like title 13 | or date in them - the title, date and optional tags are parsed from the natural 14 | flow of the posts. 15 | 16 | **Next**: [Quick Start](/quick-start.html) 17 | -------------------------------------------------------------------------------- /cli/sample/posts/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | *Nov 18, 2015* 4 | 5 | *Docs, Tutorial* 6 | 7 | To create a sample blog follow these steps: 8 | 9 | 1. Download a [release](https://github.com/mirovarga/litepub/releases) and 10 | unpack it to a directory. 11 | 12 | 2. `cd` to the directory. 13 | 14 | 3. Create a sample blog: 15 | 16 | ``` 17 | $ ./litepub create 18 | ``` 19 | 20 | 4. Build the blog: 21 | 22 | ``` 23 | $ ./litepub build 24 | 25 | Generating: index.html Generating: tags/reference.html Generating: 26 | tags/tutorial.html Generating: tags/advanced.html Generating: tags/docs.html 27 | Generating: tags/basics.html Generating: overview.html Generating: 28 | quick-start.html Generating: installation.html Generating: creating-a-blog.html 29 | Generating: creating-posts.html Generating: 30 | generating-html-files-for-a-blog-aka-building-a-blog.html Generating: 31 | serving-a-blog.html Generating: templates.html Generating: getting-help.html 32 | 33 | ``` 34 | 35 | 5. Run the built-in server: 36 | 37 | ``` 38 | 39 | $ ./litepub serve Running on http://localhost:2703 40 | Ctrl+C to quit 41 | 42 | ``` 43 | 44 | 6. Open [http://localhost:2703](http://localhost:2703) in your browser. 45 | 46 | **Next**: [Installation](/installation.html) 47 | -------------------------------------------------------------------------------- /cli/sample/posts/serving-a-blog.md: -------------------------------------------------------------------------------- 1 | # Serving a Blog 2 | 3 | *Nov 13, 2015* 4 | 5 | *Docs, Basics* 6 | 7 | LitePub has a built-in server so you can see how a generated blog looks like in 8 | a browser. `cd` to the blog's directory and start the server: 9 | 10 | ``` 11 | $ litepub serve 12 | Running on http://localhost:2703 13 | Ctrl+C to quit 14 | ``` 15 | 16 | Now open [http://localhost:2703](http://localhost:2703) in your browser to see 17 | the generated blog. 18 | 19 | > Note that the server ignores the draft posts. 20 | 21 | ## Serving a Blog on a Different Port 22 | 23 | When starting the server you can specify a port on which to listen with the 24 | `--port` option: 25 | 26 | ``` 27 | $ litepub serve --port 3000 28 | Running on http://localhost:3000 29 | Ctrl+C to quit 30 | ``` 31 | 32 | ## Serving a Blog and Watching for Changes 33 | 34 | When creating templates or even writing posts it's quite useful to be able to 35 | immediately see the changes after refreshing the page. To tell LitePub that it 36 | should watch for changes to posts and templates use the `--watch` option: 37 | 38 | ``` 39 | $ litepub serve --watch 40 | Running on http://localhost:2703 41 | Rebuilding when posts or templates change 42 | Ctrl+C to quit 43 | ``` 44 | 45 | > Note that subdirectories in the `posts` and `templates` directories aren't watched. 46 | 47 | ## Rebuilding a Blog Before Serving 48 | 49 | Sometimes it can be useful to rebuild a blog before serving it, for example when 50 | you don't remember if you made any changes to posts or templates. To rebuild a 51 | blog before serving use the `--rebuild` option: 52 | 53 | ``` 54 | $ litepub serve --rebuild 55 | Generating: index.html 56 | Generating: tags/reference.html 57 | Generating: tags/tutorial.html 58 | Generating: tags/advanced.html 59 | Generating: tags/docs.html 60 | Generating: tags/basics.html 61 | Generating: overview.html 62 | Generating: quick-start.html 63 | Generating: installation.html 64 | Generating: creating-a-blog.html 65 | Generating: creating-posts.html 66 | Generating: generating-html-files-for-a-blog-aka-building-a-blog.html 67 | Generating: serving-a-blog.html 68 | Generating: templates.html 69 | Generating: getting-help.html 70 | Running on http://localhost:2703 71 | Ctrl+C to quit 72 | ``` 73 | 74 | ## The **serve** Command Reference 75 | 76 | ``` 77 | Usage: 78 | litepub serve [] [-R, --rebuild] [-p, --port ] [-w, --watch] [-q, --quiet] 79 | 80 | Arguments: 81 | The directory to create the blog in or look for; it will be created if 82 | it doesn't exist (only when creating a blog) [default: .] 83 | 84 | Options: 85 | -R, --rebuild Rebuild the blog before serving 86 | -p, --port The port to listen on [default: 2703] 87 | -w, --watch Rebuild the blog when posts or templates change 88 | -q, --quiet Show only errors 89 | ``` 90 | 91 | **Next**: [Templates](/templates.html) 92 | -------------------------------------------------------------------------------- /cli/sample/posts/templates.md: -------------------------------------------------------------------------------- 1 | # Templates 2 | 3 | *Nov 12, 2015* 4 | 5 | *Docs, Advanced* 6 | 7 | The `create` command adds sample templates to the `templates` directory. Of 8 | course, you can change them or create your own from scratch. 9 | 10 | LitePub uses the Go [html/template](https://golang.org/pkg/html/template/) 11 | package to define the templates. 12 | 13 | > Design changes require no knowledge of Go templates. However changes that affect what data is displayed will require it less or more (depending on the change). 14 | 15 | ## Structure 16 | 17 | There are four required files in the `templates` directory: 18 | 19 | ```bash 20 | templates/ # the templates and accompanying files (html, css, js, png, etc.) 21 | layout.tmpl 22 | index.tmpl 23 | post.tmpl 24 | tag.tmpl 25 | ``` 26 | 27 | - `layout.tmpl` defines the common layout for the home page (`index.tmpl`), post 28 | pages (`post.tmpl`) and tag pages (`tag.tmpl`) 29 | - `index.tmpl` is used when generating the home page (`index.html`) 30 | - `post.tmpl` is used when generating post pages 31 | - and `tag.tmpl` is used when generating tag pages 32 | 33 | Besides the four files there can be any number of `html`, `css`, `js`, `png`, 34 | etc. files that are used by the `.tmpl` files. 35 | 36 | > If you're not familiar with Go templates, some things in the next sections can be unclear. 37 | 38 | ## Data 39 | 40 | Templates have access to data they are meant to display. There are two types of 41 | data: `Post`s and `Tag`s. 42 | 43 | ### Posts 44 | 45 | A `Post` has the following properties: 46 | 47 | - `Title` - the post title 48 | - `Content` - the content of the post as Markdown text 49 | - `Written` - the post's date 50 | - `Tags` - an array of tags the post is tagged with (can be empty) 51 | - `Draft` - `true` if the post is a draft 52 | 53 | > To get a post's page URL in a template use the `slug` function (described below) like this: `A Post`. 54 | 55 | ### Tags 56 | 57 | A `Tag` has the following properties: 58 | 59 | - `Name` - the tag name 60 | - `Posts` - an array of `Post`s that are tagged with the tag sorted by `Written` 61 | in descending order 62 | 63 | > To get a tag's page URL in a template use the `slug` function (described below) like this: `A Tag`. 64 | 65 | The `index.tmpl` template has access to an array of `Post`s sorted by `Written` 66 | in descending order. The `post.tmpl` template has access to the `Post` it 67 | displays. The `tag.tmpl` template has access to the `Tag` it displays. 68 | 69 | ## Functions 70 | 71 | The `index.tmpl`, `post.tmpl` and `tag.tmpl` templates have access to the 72 | following functions: 73 | 74 | ### html 75 | 76 | Converts a Markdown string to a raw HTML, for example `{{.Content | html}}`. 77 | 78 | ### summary 79 | 80 | Extracts the first paragraph of a Markdown string that isn't a header (doesn't 81 | start with a `#`), for example `{{.Content | summary | html}}`. 82 | 83 | ### even 84 | 85 | Returns `true` if an integer is even, for example 86 | `{{if even $i}}{{end}}`. 87 | 88 | ### inc 89 | 90 | Increments an integer by one, for example 91 | `{{if or (eq (inc $i) $l) (not (even $i))}}{{end}}`. 92 | 93 | ### slug 94 | 95 | Slugifies a string, for example `A Post`. 96 | 97 | > The available functions represent my needs when converting my handmade blog to a generated one. 98 | 99 | **Next**: [Getting Help](/getting-help.html) 100 | -------------------------------------------------------------------------------- /cli/sample/templates/css/font.min.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Raleway;font-style:normal;font-weight:300;src:local('Raleway Light'),local('Raleway-Light'),url(https://fonts.gstatic.com/s/raleway/v9/-_Ctzj9b56b8RgXW8FArifk_vArhqVIZ0nv9q090hN8.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2212,U+2215,U+E0FF,U+EFFD,U+F000}@font-face{font-family:Raleway;font-style:normal;font-weight:400;src:local('Raleway'),url(https://fonts.gstatic.com/s/raleway/v9/0dTEPzkLWceF7z0koJaX1A.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2212,U+2215,U+E0FF,U+EFFD,U+F000}@font-face{font-family:Raleway;font-style:normal;font-weight:600;src:local('Raleway SemiBold'),local('Raleway-SemiBold'),url(https://fonts.gstatic.com/s/raleway/v9/xkvoNo9fC8O2RDydKj12b_k_vArhqVIZ0nv9q090hN8.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2212,U+2215,U+E0FF,U+EFFD,U+F000} 2 | -------------------------------------------------------------------------------- /cli/sample/templates/css/main.min.css: -------------------------------------------------------------------------------- 1 | a,a:active,a:focus,a:hover{color:#888;text-decoration:none}a,footer,header{color:#888}body{font-size:1.8rem}a{border-bottom:1px dotted #888}a:active,a:focus,a:hover{border-bottom:1px solid #888}a.logo{font-size:2rem;font-weight:700}a.logo img{vertical-align:text-top}a.img,a.logo{border-bottom:none}blockquote{font-style:italic;border-left:.2rem solid #bbb;margin:0;padding-left:2rem}pre{font-size:1.6rem;padding:0!important}code{border:none}header{margin-top:2rem;margin-bottom:4rem}footer{margin-top:8rem;margin-bottom:2rem}.what{text-align:center;margin-bottom:6rem} 2 | -------------------------------------------------------------------------------- /cli/sample/templates/css/normalize.min.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}td,th{padding:0} 2 | -------------------------------------------------------------------------------- /cli/sample/templates/css/prism.min.css: -------------------------------------------------------------------------------- 1 | code[class*=language-],pre[class*=language-]{color:#000;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;direction:ltr;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#a67f59;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.function{color:#DD4A68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help} 2 | -------------------------------------------------------------------------------- /cli/sample/templates/css/skeleton.min.css: -------------------------------------------------------------------------------- 1 | .container{position:relative;width:100%;max-width:960px;margin:0 auto;padding:0 20px;box-sizing:border-box}.column,.columns{width:100%;float:left;box-sizing:border-box}@media (min-width:400px){.container{width:85%;padding:0}}@media (min-width:550px){.container{width:80%}.column,.columns{margin-left:4%}.column:first-child,.columns:first-child{margin-left:0}.one.column,.one.columns{width:4.66666666667%}.two.columns{width:13.3333333333%}.three.columns{width:22%}.four.columns{width:30.6666666667%}.five.columns{width:39.3333333333%}.six.columns{width:48%}.seven.columns{width:56.6666666667%}.eight.columns{width:65.3333333333%}.nine.columns{width:74%}.ten.columns{width:82.6666666667%}.eleven.columns{width:91.3333333333%}.twelve.columns{width:100%;margin-left:0}.one-third.column{width:30.6666666667%}.two-thirds.column{width:65.3333333333%}.one-half.column{width:48%}.offset-by-one.column,.offset-by-one.columns{margin-left:8.66666666667%}.offset-by-two.column,.offset-by-two.columns{margin-left:17.3333333333%}.offset-by-three.column,.offset-by-three.columns{margin-left:26%}.offset-by-four.column,.offset-by-four.columns{margin-left:34.6666666667%}.offset-by-five.column,.offset-by-five.columns{margin-left:43.3333333333%}.offset-by-six.column,.offset-by-six.columns{margin-left:52%}.offset-by-seven.column,.offset-by-seven.columns{margin-left:60.6666666667%}.offset-by-eight.column,.offset-by-eight.columns{margin-left:69.3333333333%}.offset-by-nine.column,.offset-by-nine.columns{margin-left:78%}.offset-by-ten.column,.offset-by-ten.columns{margin-left:86.6666666667%}.offset-by-eleven.column,.offset-by-eleven.columns{margin-left:95.3333333333%}.offset-by-one-third.column,.offset-by-one-third.columns{margin-left:34.6666666667%}.offset-by-two-thirds.column,.offset-by-two-thirds.columns{margin-left:69.3333333333%}.offset-by-one-half.column,.offset-by-one-half.columns{margin-left:52%}}html{font-size:62.5%}body{font-size:1.5em;line-height:1.6;font-weight:400;font-family:Raleway,HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;color:#222}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:2rem;font-weight:300}h1{font-size:4rem;line-height:1.2;letter-spacing:-.1rem}h2{font-size:3.6rem;line-height:1.25;letter-spacing:-.1rem}h3{font-size:3rem;line-height:1.3;letter-spacing:-.1rem}h4{font-size:2.4rem;line-height:1.35;letter-spacing:-.08rem}h5{font-size:1.8rem;line-height:1.5;letter-spacing:-.05rem}h6{font-size:1.5rem;line-height:1.6;letter-spacing:0}@media (min-width:550px){h1{font-size:5rem}h2{font-size:4.2rem}h3{font-size:3.6rem}h4{font-size:3rem}h5{font-size:2.4rem}h6{font-size:1.5rem}}p{margin-top:0}a{color:#1EAEDB}a:hover{color:#0FA0CE}.button,button,input[type=button],input[type=reset],input[type=submit]{display:inline-block;height:38px;padding:0 30px;color:#555;text-align:center;font-size:11px;font-weight:600;line-height:38px;letter-spacing:.1rem;text-transform:uppercase;text-decoration:none;white-space:nowrap;background-color:transparent;border-radius:4px;border:1px solid #bbb;cursor:pointer;box-sizing:border-box}.button:focus,.button:hover,button:focus,button:hover,input[type=button]:focus,input[type=button]:hover,input[type=reset]:focus,input[type=reset]:hover,input[type=submit]:focus,input[type=submit]:hover{color:#333;border-color:#888;outline:0}.button.button-primary,button.button-primary,input[type=button].button-primary,input[type=reset].button-primary,input[type=submit].button-primary{color:#FFF;background-color:#33C3F0;border-color:#33C3F0}.button.button-primary:focus,.button.button-primary:hover,button.button-primary:focus,button.button-primary:hover,input[type=button].button-primary:focus,input[type=button].button-primary:hover,input[type=reset].button-primary:focus,input[type=reset].button-primary:hover,input[type=submit].button-primary:focus,input[type=submit].button-primary:hover{color:#FFF;background-color:#1EAEDB;border-color:#1EAEDB}input[type=email],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=url],select,textarea{height:38px;padding:6px 10px;background-color:#fff;border:1px solid #D1D1D1;border-radius:4px;box-shadow:none;box-sizing:border-box}input[type=email],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=url],textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none}textarea{min-height:65px;padding-top:6px;padding-bottom:6px}input[type=email]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=url]:focus,select:focus,textarea:focus{border:1px solid #33C3F0;outline:0}label,legend{display:block;margin-bottom:.5rem;font-weight:600}fieldset{padding:0;border-width:0}input[type=checkbox],input[type=radio]{display:inline}label>.label-body{display:inline-block;margin-left:.5rem;font-weight:400}ul{list-style:circle inside}ol{list-style:decimal inside}ol,ul{padding-left:0;margin-top:0}ol ol,ol ul,ul ol,ul ul{margin:1.5rem 0 1.5rem 3rem;font-size:90%}li{margin-bottom:1rem}code{padding:.2rem .5rem;margin:0 .2rem;font-size:90%;white-space:nowrap;background:#F1F1F1;border:1px solid #E1E1E1;border-radius:4px}pre>code{display:block;padding:1rem 1.5rem;white-space:pre}td,th{padding:12px 15px;text-align:left;border-bottom:1px solid #E1E1E1}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}.button,button{margin-bottom:1rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}.u-full-width{width:100%;box-sizing:border-box}.u-max-full-width{max-width:100%;box-sizing:border-box}.u-pull-right{float:right}.u-pull-left{float:left}hr{margin-top:3rem;margin-bottom:3.5rem;border-width:0;border-top:1px solid #E1E1E1}.container:after,.row:after,.u-cf{content:"";display:table;clear:both} 2 | -------------------------------------------------------------------------------- /cli/sample/templates/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirovarga/litepub/ddfb969bdf6cc8ac5d5496381ed69edde665adfa/cli/sample/templates/favicon.ico -------------------------------------------------------------------------------- /cli/sample/templates/images/email-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirovarga/litepub/ddfb969bdf6cc8ac5d5496381ed69edde665adfa/cli/sample/templates/images/email-16.png -------------------------------------------------------------------------------- /cli/sample/templates/images/email-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirovarga/litepub/ddfb969bdf6cc8ac5d5496381ed69edde665adfa/cli/sample/templates/images/email-24.png -------------------------------------------------------------------------------- /cli/sample/templates/images/github-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirovarga/litepub/ddfb969bdf6cc8ac5d5496381ed69edde665adfa/cli/sample/templates/images/github-16.png -------------------------------------------------------------------------------- /cli/sample/templates/images/github-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirovarga/litepub/ddfb969bdf6cc8ac5d5496381ed69edde665adfa/cli/sample/templates/images/github-24.png -------------------------------------------------------------------------------- /cli/sample/templates/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirovarga/litepub/ddfb969bdf6cc8ac5d5496381ed69edde665adfa/cli/sample/templates/images/logo.png -------------------------------------------------------------------------------- /cli/sample/templates/images/twitter-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirovarga/litepub/ddfb969bdf6cc8ac5d5496381ed69edde665adfa/cli/sample/templates/images/twitter-16.png -------------------------------------------------------------------------------- /cli/sample/templates/images/twitter-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirovarga/litepub/ddfb969bdf6cc8ac5d5496381ed69edde665adfa/cli/sample/templates/images/twitter-24.png -------------------------------------------------------------------------------- /cli/sample/templates/index.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | A lightweight static blog generator 3 | {{end}} 4 | 5 | {{define "content"}} 6 |
7 |
8 |

A Lightweight Static Blog Generator

9 |
10 |
11 |

12 | Single Binary · No Configuration · Posts in Markdown 13 | · No Front Matter · Tags · Drafts · 14 | Built-in Server 15 |

16 |
17 |
18 |

19 | Get Started  20 | Learn More 21 |

22 |
23 |
24 | 25 |
26 |
27 | {{$l := len .}} 28 | {{range $i, $e := .}} 29 | {{if even $i}}
{{end}} 30 |
31 |

{{$e.Title}}

32 | 33 | 34 | {{range $e.Tags}} 35 | {{.}}  36 | {{end}} 37 | 38 | 39 | {{(printf "%s [Read more](/%s.html)" ($e.Content | summary) (.Title | slug)) | html}} 40 |
41 | {{if or (eq (inc $i) $l) (not (even $i))}}
{{end}} 42 | {{end}} 43 |
44 |
45 | {{end}} 46 | -------------------------------------------------------------------------------- /cli/sample/templates/js/prism.min.js: -------------------------------------------------------------------------------- 1 | /* http://prismjs.com/download.html?themes=prism&languages=markup+bash+markdown */ 2 | var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(){var e=/\blang(?:uage)?-(?!\*)(\w+)\b/i,t=_self.Prism={util:{encode:function(e){return e instanceof n?new n(e.type,t.util.encode(e.content),e.alias):"Array"===t.util.type(e)?e.map(t.util.encode):e.replace(/&/g,"&").replace(/e.length)break e;if(!(d instanceof a)){u.lastIndex=0;var m=u.exec(d);if(m){c&&(f=m[1].length);var y=m.index-1+f,m=m[0].slice(f),v=m.length,k=y+v,b=d.slice(0,y+1),w=d.slice(k+1),P=[p,1];b&&P.push(b);var A=new a(i,g?t.tokenize(m,g):m,h);P.push(A),w&&P.push(w),Array.prototype.splice.apply(r,P)}}}}}return r},hooks:{all:{},add:function(e,n){var a=t.hooks.all;a[e]=a[e]||[],a[e].push(n)},run:function(e,n){var a=t.hooks.all[e];if(a&&a.length)for(var r,l=0;r=a[l++];)r(n)}}},n=t.Token=function(e,t,n){this.type=e,this.content=t,this.alias=n};if(n.stringify=function(e,a,r){if("string"==typeof e)return e;if("Array"===t.util.type(e))return e.map(function(t){return n.stringify(t,a,e)}).join("");var l={type:e.type,content:n.stringify(e.content,a,r),tag:"span",classes:["token",e.type],attributes:{},language:a,parent:r};if("comment"==l.type&&(l.attributes.spellcheck="true"),e.alias){var i="Array"===t.util.type(e.alias)?e.alias:[e.alias];Array.prototype.push.apply(l.classes,i)}t.hooks.run("wrap",l);var o="";for(var s in l.attributes)o+=(o?" ":"")+s+'="'+(l.attributes[s]||"")+'"';return"<"+l.tag+' class="'+l.classes.join(" ")+'" '+o+">"+l.content+""},!_self.document)return _self.addEventListener?(_self.addEventListener("message",function(e){var n=JSON.parse(e.data),a=n.language,r=n.code,l=n.immediateClose;_self.postMessage(t.highlight(r,t.languages[a],a)),l&&_self.close()},!1),_self.Prism):_self.Prism;var a=document.getElementsByTagName("script");return a=a[a.length-1],a&&(t.filename=a.src,document.addEventListener&&!a.hasAttribute("data-manual")&&document.addEventListener("DOMContentLoaded",t.highlightAll)),_self.Prism}();"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); 3 | Prism.languages.markup={comment://,prolog:/<\?[\w\W]+?\?>/,doctype://,cdata://i,tag:{pattern:/<\/?(?!\d)[^\s>\/=.$<]+(?:\s+[^\s>\/=]+(?:=(?:("|')(?:\\\1|\\?(?!\1)[\w\W])*\1|[^\s'">=]+))?)*\s*\/?>/i,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"attr-value":{pattern:/=(?:('|")[\w\W]*?(\1)|[^\s>]+)/i,inside:{punctuation:/[=>"']/}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:/&#?[\da-z]{1,8};/i},Prism.hooks.add("wrap",function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))}),Prism.languages.xml=Prism.languages.markup,Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup; 4 | !function(e){var t={variable:[{pattern:/\$?\(\([\w\W]+?\)\)/,inside:{variable:[{pattern:/(^\$\(\([\w\W]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b-?(?:0x[\dA-Fa-f]+|\d*\.?\d+(?:[Ee]-?\d+)?)\b/,operator:/--?|-=|\+\+?|\+=|!=?|~|\*\*?|\*=|\/=?|%=?|<<=?|>>=?|<=?|>=?|==?|&&?|&=|\^=?|\|\|?|\|=|\?|:/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\([^)]+\)|`[^`]+`/,inside:{variable:/^\$\(|^`|\)$|`$/}},/\$(?:[a-z0-9_#\?\*!@]+|\{[^}]+\})/i]};e.languages.bash={shebang:{pattern:/^#!\s*\/bin\/bash|^#!\s*\/bin\/sh/,alias:"important"},comment:{pattern:/(^|[^"{\\])#.*/,lookbehind:!0},string:[{pattern:/((?:^|[^<])<<\s*)(?:"|')?(\w+?)(?:"|')?\s*\r?\n(?:[\s\S])*?\r?\n\2/g,lookbehind:!0,inside:t},{pattern:/("|')(?:\\?[\s\S])*?\1/g,inside:t}],variable:t.variable,"function":{pattern:/(^|\s|;|\||&)(?:alias|apropos|apt-get|aptitude|aspell|awk|basename|bash|bc|bg|builtin|bzip2|cal|cat|cd|cfdisk|chgrp|chmod|chown|chroot|chkconfig|cksum|clear|cmp|comm|command|cp|cron|crontab|csplit|cut|date|dc|dd|ddrescue|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|du|egrep|eject|enable|env|ethtool|eval|exec|expand|expect|export|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|getopts|git|grep|groupadd|groupdel|groupmod|groups|gzip|hash|head|help|hg|history|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|jobs|join|kill|killall|less|link|ln|locate|logname|logout|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|make|man|mkdir|mkfifo|mkisofs|mknod|more|most|mount|mtools|mtr|mv|mmv|nano|netstat|nice|nl|nohup|notify-send|nslookup|open|op|passwd|paste|pathchk|ping|pkill|popd|pr|printcap|printenv|printf|ps|pushd|pv|pwd|quota|quotacheck|quotactl|ram|rar|rcp|read|readarray|readonly|reboot|rename|renice|remsync|rev|rm|rmdir|rsync|screen|scp|sdiff|sed|seq|service|sftp|shift|shopt|shutdown|sleep|slocate|sort|source|split|ssh|stat|strace|su|sudo|sum|suspend|sync|tail|tar|tee|test|time|timeout|times|touch|top|traceroute|trap|tr|tsort|tty|type|ulimit|umask|umount|unalias|uname|unexpand|uniq|units|unrar|unshar|uptime|useradd|userdel|usermod|users|uuencode|uudecode|v|vdir|vi|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yes|zip)(?=$|\s|;|\||&)/,lookbehind:!0},keyword:{pattern:/(^|\s|;|\||&)(?:let|:|\.|if|then|else|elif|fi|for|break|continue|while|in|case|function|select|do|done|until|echo|exit|return|set|declare)(?=$|\s|;|\||&)/,lookbehind:!0},"boolean":{pattern:/(^|\s|;|\||&)(?:true|false)(?=$|\s|;|\||&)/,lookbehind:!0},operator:/&&?|\|\|?|==?|!=?|<<>|<=?|>=?|=~/,punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];]/};var a=t.variable[1].inside;a["function"]=e.languages.bash["function"],a.keyword=e.languages.bash.keyword,a.boolean=e.languages.bash.boolean,a.operator=e.languages.bash.operator,a.punctuation=e.languages.bash.punctuation}(Prism); 5 | Prism.languages.markdown=Prism.languages.extend("markup",{}),Prism.languages.insertBefore("markdown","prolog",{blockquote:{pattern:/^>(?:[\t ]*>)*/m,alias:"punctuation"},code:[{pattern:/^(?: {4}|\t).+/m,alias:"keyword"},{pattern:/``.+?``|`[^`\n]+`/,alias:"keyword"}],title:[{pattern:/\w+.*(?:\r?\n|\r)(?:==+|--+)/,alias:"important",inside:{punctuation:/==+$|--+$/}},{pattern:/(^\s*)#+.+/m,lookbehind:!0,alias:"important",inside:{punctuation:/^#+|#+$/}}],hr:{pattern:/(^\s*)([*-])([\t ]*\2){2,}(?=\s*$)/m,lookbehind:!0,alias:"punctuation"},list:{pattern:/(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m,lookbehind:!0,alias:"punctuation"},"url-reference":{pattern:/!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/,inside:{variable:{pattern:/^(!?\[)[^\]]+/,lookbehind:!0},string:/(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/,punctuation:/^[\[\]!:]|[<>]/},alias:"url"},bold:{pattern:/(^|[^\\])(\*\*|__)(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,lookbehind:!0,inside:{punctuation:/^\*\*|^__|\*\*$|__$/}},italic:{pattern:/(^|[^\\])([*_])(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,lookbehind:!0,inside:{punctuation:/^[*_]|[*_]$/}},url:{pattern:/!?\[[^\]]+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)| ?\[[^\]\n]*\])/,inside:{variable:{pattern:/(!?\[)[^\]]+(?=\]$)/,lookbehind:!0},string:{pattern:/"(?:\\.|[^"\\])*"(?=\)$)/}}}}),Prism.languages.markdown.bold.inside.url=Prism.util.clone(Prism.languages.markdown.url),Prism.languages.markdown.italic.inside.url=Prism.util.clone(Prism.languages.markdown.url),Prism.languages.markdown.bold.inside.italic=Prism.util.clone(Prism.languages.markdown.italic),Prism.languages.markdown.italic.inside.bold=Prism.util.clone(Prism.languages.markdown.bold); 6 | -------------------------------------------------------------------------------- /cli/sample/templates/layout.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | LitePub: {{template "title" .}} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 |
25 |
26 | 29 | 30 | 31 | Twitter 32 |   33 | 34 | GitGub 35 |   36 | 37 | Email 38 | 39 | 40 |
41 |
42 |
43 | 44 | {{template "content" .}} 45 | 46 | 72 |
73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /cli/sample/templates/post.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | {{.Title}} 3 | {{end}} 4 | 5 | {{define "content"}} 6 |
7 |
8 |

{{.Title}}

9 |

10 | 11 | {{range .Tags}} 12 | {{.}}  13 | {{end}} 14 | 15 |

16 | {{.Content | html}} 17 |
18 |
19 | {{end}} 20 | -------------------------------------------------------------------------------- /cli/sample/templates/tag.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | Posts tagged {{.Name}} 3 | {{end}} 4 | 5 | {{define "content"}} 6 |
7 |
8 |

Posts Tagged {{.Name}}

9 |
10 |
11 | 12 |
13 |
14 | {{$l := len .Posts}} 15 | {{range $i, $e := .Posts}} 16 | {{if even $i}}
{{end}} 17 |
18 |

{{$e.Title}}

19 | 20 | 21 | {{range $e.Tags}} 22 | {{.}}  23 | {{end}} 24 | 25 | 26 | {{(printf "%s [Read more](/%s.html)" ($e.Content | summary) (.Title | slug)) | html}} 27 |
28 | {{if or (eq (inc $i) $l) (not (even $i))}}
{{end}} 29 | {{end}} 30 |
31 |
32 | {{end}} 33 | -------------------------------------------------------------------------------- /cli/serve.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "net/http" 5 | "path/filepath" 6 | 7 | "github.com/fsnotify/fsnotify" 8 | ) 9 | 10 | const defaultPort = "2703" 11 | 12 | func serve(arguments map[string]interface{}) int { 13 | dir := arguments[""].(string) 14 | 15 | if arguments["--rebuild"].(int) == 1 { 16 | build(map[string]interface{}{"": dir}) 17 | } 18 | 19 | port, ok := arguments["--port"].([]string) 20 | if !ok { 21 | port[0] = defaultPort 22 | } 23 | 24 | watch := arguments["--watch"].(int) 25 | 26 | if watch == 1 { 27 | go watchDirs(dir) 28 | } 29 | 30 | log.Infof("Running on http://localhost:%s\n", port[0]) 31 | if watch == 1 { 32 | log.Infof("Rebuilding when posts or templates change\n") 33 | } 34 | log.Infof("Ctrl+C to quit\n") 35 | 36 | http.ListenAndServe(":"+port[0], http.FileServer(http.Dir(filepath.Join(dir, outputDir)))) 37 | return 0 38 | } 39 | 40 | func watchDirs(dir string) { 41 | watcher, _ := fsnotify.NewWatcher() 42 | defer watcher.Close() 43 | 44 | watcher.Add(filepath.Join(dir, postsDir)) 45 | watcher.Add(filepath.Join(dir, templatesDir)) 46 | 47 | for { 48 | select { 49 | case <-watcher.Events: 50 | build(map[string]interface{}{"": dir}) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /cli/usage.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | const usage = ` 4 | LitePub 0.5.7 [github.com/mirovarga/litepub] 5 | Copyright (c) 2024 Miro Varga [mirovarga.com] 6 | 7 | Usage: 8 | litepub create [] [-s, --skeleton] [-q, --quiet] 9 | litepub build [] [-q, --quiet] 10 | litepub serve [] [-R, --rebuild] [-p, --port ] [-w, --watch] [-q, --quiet] 11 | 12 | Arguments: 13 | The directory to create the blog in or look for; it will be created if 14 | it doesn't exist (only when creating a blog) [default: .] 15 | 16 | Options: 17 | -s, --skeleton Don't create sample posts and templates 18 | -R, --rebuild Rebuild the blog before serving 19 | -p, --port The port to listen on [default: 2703] 20 | -w, --watch Rebuild the blog when posts or templates change 21 | -q, --quiet Show only errors 22 | -h, --help Show this screen 23 | -v, --version Show version 24 | ` 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mirovarga/litepub 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 7 | github.com/fsnotify/fsnotify v1.4.9 8 | github.com/gosimple/slug v1.10.0 9 | github.com/russross/blackfriday v1.6.0 10 | github.com/termie/go-shutil v0.0.0-20140729215957-bcacb06fecae 11 | ) 12 | 13 | require ( 14 | github.com/gosimple/unidecode v1.0.0 // indirect 15 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= 2 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= 3 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 4 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 5 | github.com/gosimple/slug v1.10.0 h1:3XbiQua1IpCdrvuntWvGBxVm+K99wCSxJjlxkP49GGQ= 6 | github.com/gosimple/slug v1.10.0/go.mod h1:MICb3w495l9KNdZm+Xn5b6T2Hn831f9DMxiJ1r+bAjw= 7 | github.com/gosimple/unidecode v1.0.0 h1:kPdvM+qy0tnk4/BrnkrbdJ82xe88xn7c9hcaipDz4dQ= 8 | github.com/gosimple/unidecode v1.0.0/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= 9 | github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= 10 | github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= 11 | github.com/termie/go-shutil v0.0.0-20140729215957-bcacb06fecae h1:vgGSvdW5Lqg+I1aZOlG32uyE6xHpLdKhZzcTEktz5wM= 12 | github.com/termie/go-shutil v0.0.0-20140729215957-bcacb06fecae/go.mod h1:quDq6Se6jlGwiIKia/itDZxqC5rj6/8OdFyMMAwTxCs= 13 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 14 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= 15 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 16 | -------------------------------------------------------------------------------- /lib/blog.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | ) 7 | 8 | // Blog is just a collection of Posts. 9 | type Blog []Post 10 | 11 | // PostsByDate returns Posts of the Blog sorted in ascending or descending order 12 | // (if asc == false). If includeDrafts == true draft Posts are also included. 13 | // If withTags is present only Posts having the tags are included. 14 | func (b Blog) PostsByDate(asc, includeDrafts bool, withTags ...string) []Post { 15 | sortByDate(b, asc) 16 | 17 | var posts []Post 18 | for _, post := range b { 19 | if post.Draft && !includeDrafts { 20 | continue 21 | } 22 | posts = append(posts, post) 23 | } 24 | 25 | return filterByTags(posts, withTags...) 26 | } 27 | 28 | // Tags returns all tags used in Posts of the Blog. If lookInDrafts == true 29 | // draft Posts are also checked. 30 | func (b Blog) Tags(lookInDrafts bool) []string { 31 | tags := make(map[string]string) 32 | for _, post := range b { 33 | if post.Draft && !lookInDrafts { 34 | continue 35 | } 36 | for _, tag := range post.Tags { 37 | tags[tag] = tag 38 | } 39 | } 40 | 41 | blogTags := make([]string, len(tags)) 42 | i := 0 43 | for tag := range tags { 44 | blogTags[i] = tag 45 | i++ 46 | } 47 | return blogTags 48 | } 49 | 50 | func (b Blog) Len() int { 51 | return len(b) 52 | } 53 | 54 | func (b Blog) Swap(i, j int) { 55 | b[i], b[j] = b[j], b[i] 56 | } 57 | 58 | func (b Blog) Less(i, j int) bool { 59 | return b[i].Written.Before(b[j].Written) 60 | } 61 | 62 | // Post is a Blog's post. 63 | type Post struct { 64 | Title string 65 | 66 | // Content of the post (can use Markdown). 67 | Content string 68 | Written time.Time 69 | Tags []string 70 | Draft bool 71 | } 72 | 73 | func sortByDate(blog Blog, asc bool) { 74 | if asc { 75 | sort.Sort(blog) 76 | } else { 77 | sort.Sort(sort.Reverse(blog)) 78 | } 79 | } 80 | 81 | func filterByTags(blog Blog, tags ...string) []Post { 82 | if len(tags) == 0 { 83 | return blog 84 | } 85 | 86 | var posts []Post 87 | for _, post := range blog { 88 | for _, postTag := range post.Tags { 89 | for _, tag := range tags { 90 | if postTag == tag { 91 | posts = append(posts, post) 92 | } 93 | } 94 | } 95 | } 96 | return posts 97 | } 98 | -------------------------------------------------------------------------------- /lib/html.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/gosimple/slug" 11 | "github.com/russross/blackfriday" 12 | "github.com/termie/go-shutil" 13 | ) 14 | 15 | // ProgressFunc is used to monitor progress of generating a Blog. It is called 16 | // before a file generation is started. 17 | type ProgressFunc func(path string) 18 | 19 | // StaticBlogGenerator generates Blogs to static HTML files. 20 | type StaticBlogGenerator struct { 21 | templatesDir string 22 | outputDir string 23 | progressFunc ProgressFunc 24 | indexTemplate *template.Template 25 | postTemplate *template.Template 26 | tagTemplate *template.Template 27 | posts []Post 28 | postsByTag map[string][]Post 29 | } 30 | 31 | // Generate generates a Blog to static HTML files. 32 | func (g StaticBlogGenerator) Generate() error { 33 | err := g.prepareOutputDir() 34 | if err != nil { 35 | return fmt.Errorf("failed to prepare output directory: %s", err) 36 | } 37 | 38 | err = g.generateIndex() 39 | if err != nil { 40 | return fmt.Errorf("failed to generate index: %s", err) 41 | } 42 | 43 | err = g.generateTags() 44 | if err != nil { 45 | return fmt.Errorf("failed to generate tags: %s", err) 46 | } 47 | 48 | err = g.generatePosts() 49 | if err != nil { 50 | return fmt.Errorf("failed to generate posts: %s", err) 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func (g StaticBlogGenerator) prepareOutputDir() error { 57 | os.RemoveAll(g.outputDir) 58 | 59 | err := shutil.CopyTree(g.templatesDir, g.outputDir, 60 | &shutil.CopyTreeOptions{ 61 | Symlinks: true, 62 | Ignore: func(string, []os.FileInfo) []string { 63 | return []string{"layout.tmpl", "index.tmpl", "post.tmpl", "tag.tmpl"} 64 | }, 65 | CopyFunction: shutil.Copy, 66 | IgnoreDanglingSymlinks: false, 67 | }) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | return os.Mkdir(filepath.Join(g.outputDir, "tags"), 0700) 73 | } 74 | 75 | func (g StaticBlogGenerator) generateIndex() error { 76 | return g.generatePage(g.indexTemplate, "index.html", g.posts) 77 | } 78 | 79 | func (g StaticBlogGenerator) generatePosts() error { 80 | for _, post := range g.posts { 81 | err := g.generatePage(g.postTemplate, slug.Make(post.Title)+".html", post) 82 | if err != nil { 83 | return err 84 | } 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func (g StaticBlogGenerator) generateTags() error { 91 | for tag, posts := range g.postsByTag { 92 | err := g.generatePage(g.tagTemplate, 93 | filepath.Join("tags", slug.Make(tag)+".html"), struct { 94 | Name string 95 | Posts []Post 96 | }{tag, posts}) 97 | if err != nil { 98 | return err 99 | } 100 | } 101 | 102 | return nil 103 | } 104 | 105 | func (g StaticBlogGenerator) generatePage(template *template.Template, 106 | path string, data interface{}) error { 107 | g.progressFunc(path) 108 | 109 | pageFile, err := os.OpenFile(filepath.Join(g.outputDir, path), 110 | os.O_CREATE|os.O_WRONLY, 0600) 111 | if err != nil { 112 | return err 113 | } 114 | defer pageFile.Close() 115 | 116 | return template.Execute(pageFile, data) 117 | } 118 | 119 | // NewStaticBlogGenerator creates a StaticBlogGenerator that generates the Blog 120 | // to static HTML files in the outputDir using templates from the templatesDir. 121 | // It calls the progressFunc before generating each file. 122 | func NewStaticBlogGenerator(blog Blog, templatesDir, outputDir string, 123 | progressFunc ProgressFunc) (StaticBlogGenerator, error) { 124 | if _, err := os.Stat(templatesDir); err != nil { 125 | return StaticBlogGenerator{}, 126 | fmt.Errorf("templates directory not found: %s", templatesDir) 127 | } 128 | 129 | indexTemplate, err := createTemplate(templatesDir, "index.tmpl") 130 | if err != nil { 131 | return StaticBlogGenerator{}, err 132 | } 133 | 134 | postTemplate, err := createTemplate(templatesDir, "post.tmpl") 135 | if err != nil { 136 | return StaticBlogGenerator{}, err 137 | } 138 | 139 | tagTemplate, err := createTemplate(templatesDir, "tag.tmpl") 140 | if err != nil { 141 | return StaticBlogGenerator{}, err 142 | } 143 | 144 | posts := blog.PostsByDate(false, false) 145 | 146 | postsByTag := map[string][]Post{} 147 | for _, tag := range blog.Tags(false) { 148 | postsByTag[tag] = blog.PostsByDate(false, false, tag) 149 | } 150 | 151 | return StaticBlogGenerator{templatesDir, outputDir, progressFunc, 152 | indexTemplate, postTemplate, tagTemplate, posts, postsByTag}, nil 153 | } 154 | 155 | func createTemplate(dir, name string) (*template.Template, error) { 156 | return template.New("layout.tmpl").Funcs(templateFuncs).ParseFiles( 157 | filepath.Join(dir, "layout.tmpl"), 158 | filepath.Join(dir, name)) 159 | } 160 | 161 | var templateFuncs = template.FuncMap{ 162 | "html": html, 163 | "summary": summary, 164 | "even": even, 165 | "inc": inc, 166 | "slug": slugify, 167 | } 168 | 169 | func html(markdown string) template.HTML { 170 | html := blackfriday.MarkdownCommon([]byte(markdown)) 171 | return template.HTML(html) 172 | } 173 | 174 | func summary(content string) string { 175 | lines := strings.Split(content, "\n\n") 176 | for _, line := range lines { 177 | if !strings.HasPrefix(line, "#") { 178 | return line 179 | } 180 | } 181 | return content 182 | } 183 | 184 | func even(integer int) bool { 185 | return integer%2 == 0 186 | } 187 | 188 | func inc(integer int) int { 189 | return integer + 1 190 | } 191 | 192 | func slugify(str string) string { 193 | return slug.Make(str) 194 | } 195 | -------------------------------------------------------------------------------- /lib/markdown.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | const ( 12 | postsDir = "posts" 13 | draftDir = "draft" 14 | ) 15 | 16 | // MarkdownBlog represents a Blog stored as Markdown files in a directory. 17 | // 18 | // Posts are stored as Markdown files in the posts subdirectory of the Blog 19 | // directory. Draft Posts (ones with Draft set to true) are stored in the draft 20 | // subdirectory of the posts directory. 21 | // 22 | // So the structure looks like this: 23 | // 24 | // blog/ 25 | // posts/ 26 | // draft/ 27 | // draft1.md 28 | // ... 29 | // post1.md 30 | // post2.md 31 | // ... 32 | // 33 | // Markdown files have the following format: 34 | // 35 | // # Title 36 | // 37 | // *Jan 2, 2009* 38 | // 39 | // *tag1, tag2, ...* 40 | // 41 | // Content 42 | type MarkdownBlog struct { 43 | dir string 44 | } 45 | 46 | // NewMarkdownBlog creates a MarkdownBlog in the provided directory. 47 | // 48 | // If the directory doesn't exist it creates it. 49 | func NewMarkdownBlog(dir string) MarkdownBlog { 50 | if _, err := os.Stat(dir); err != nil { 51 | os.MkdirAll(filepath.Join(dir, postsDir, draftDir), 0700) 52 | } 53 | return MarkdownBlog{dir} 54 | } 55 | 56 | // Read creates a Blog from the Markdown files. 57 | // 58 | // If the directory doesn't exist it returns an error. 59 | func (b MarkdownBlog) Read() (Blog, error) { 60 | if _, err := os.Stat(b.dir); err != nil { 61 | return Blog{}, fmt.Errorf("blog not found: %s", b) 62 | } 63 | 64 | postsPath := filepath.Join(b.dir, postsDir) 65 | posts, err := readPosts(postsPath) 66 | if err != nil { 67 | return Blog{}, err 68 | } 69 | 70 | draftsPath := filepath.Join(postsPath, draftDir) 71 | drafts, err := readPosts(draftsPath) 72 | if err != nil { 73 | return Blog{}, err 74 | } 75 | 76 | blog := posts 77 | for _, draft := range drafts { 78 | draft.Draft = true 79 | blog = append(blog, draft) 80 | } 81 | 82 | return blog, nil 83 | } 84 | 85 | func readPosts(dir string) ([]Post, error) { 86 | postFiles, err := os.ReadDir(dir) 87 | if err != nil { 88 | return []Post{}, fmt.Errorf("failed to read posts: %s", err) 89 | } 90 | 91 | var posts []Post 92 | for _, postFile := range postFiles { 93 | // TODO dirs/files starting with '.' or '_' are drafts 94 | if postFile.IsDir() || strings.HasPrefix(postFile.Name(), ".") { 95 | continue 96 | } 97 | 98 | post, err := readPost(filepath.Join(dir, postFile.Name())) 99 | if err != nil { 100 | return []Post{}, err 101 | } 102 | posts = append(posts, post) 103 | } 104 | return posts, nil 105 | } 106 | 107 | func readPost(path string) (Post, error) { 108 | markdown, err := os.ReadFile(path) 109 | if err != nil { 110 | return Post{}, fmt.Errorf("failed to read post: %s", err) 111 | } 112 | 113 | return markdownToPost(string(markdown)) 114 | } 115 | 116 | func markdownToPost(markdown string) (Post, error) { 117 | md := strings.ReplaceAll(markdown, "\r\n", "\n") 118 | 119 | paras := strings.Split(md, "\n\n") 120 | if len(paras) < 3 { 121 | return Post{}, fmt.Errorf("title, date or content is missing") 122 | } 123 | 124 | title := strings.TrimSpace(strings.Replace(paras[0], "#", "", -1)) 125 | 126 | written, err := time.Parse("*Jan 2, 2006*", paras[1]) 127 | if err != nil { 128 | return Post{}, fmt.Errorf("failed to parse date: %s", err) 129 | } 130 | 131 | var tags []string 132 | if strings.HasPrefix(paras[2], "*") && !strings.Contains(paras[2], "\n") { 133 | tags = strings.Split(paras[2], ",") 134 | for i, tag := range tags { 135 | tags[i] = strings.TrimSpace(strings.Replace(tag, "*", "", -1)) 136 | } 137 | } 138 | 139 | var content string 140 | if len(tags) == 0 { 141 | content = strings.Join(paras[2:], "\n\n") 142 | } else { 143 | content = strings.Join(paras[3:], "\n\n") 144 | } 145 | 146 | return Post{title, content, written, tags, false}, nil 147 | } 148 | -------------------------------------------------------------------------------- /lib/markdown_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import "testing" 4 | 5 | func TestMarkdownToPostWithLF(t *testing.T) { 6 | lf := "# A title\n\n*Aug 10, 2021*\n\n*Test, Markdown*\n\nTesting LF\n" 7 | 8 | _, err := markdownToPost(lf) 9 | if err != nil { 10 | t.Fatal(err) 11 | } 12 | } 13 | 14 | func TestMarkdownToPostWithCRLF(t *testing.T) { 15 | crlf := "# A title\r\n\r\n*Aug 10, 2021*\r\n\r\n*Test, Markdown*\r\n\r\nTesting CRLF\r\n" 16 | 17 | _, err := markdownToPost(crlf) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mirovarga/litepub/cli" 7 | ) 8 | 9 | func main() { 10 | os.Exit(cli.Run()) 11 | } 12 | --------------------------------------------------------------------------------