Add drafts support (#17)

* add src and target ext as template methods

* add include drafts config

* default to draft on jorge post

* skip drafts from site indexes and rendering

* fix stat usage for file detection

* add site.Build test

* test build site with and without drafts

* add templ IsPost helper

* document drafts in readme

* document drafts in tutorial
This commit is contained in:
Facundo Olano 2024-02-27 12:24:45 -03:00 committed by GitHub
parent e1280f07d5
commit 6f27afacf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 240 additions and 40 deletions

View file

@ -66,6 +66,7 @@ date: 2024-02-21 13:39:59
layout: post
lang: en
tags: []
draft: true
---
#+OPTIONS: toc:nil num:nil
#+LANGUAGE: en
@ -83,6 +84,12 @@ this is my *first* post.
EOF
```
Posts created with `jorge post` are drafts by default. Remove the `draft: true` to mark it ready for publication:
``` bash
$ sed -i '' '/^draft: true$/d' src/blog/my-first-post.org
```
Finally, you can render a minified version of your site with `jorge build`:
```

View file

@ -19,6 +19,7 @@ date: %s
layout: post
lang: %s
tags: []
draft: true
---
`
@ -56,7 +57,7 @@ func (cmd *Post) Run(ctx *kong.Context) error {
}
// if file already exists, prompt user for a different one
if _, err := os.Stat(path); os.IsExist(err) {
if _, err := os.Stat(path); err == nil {
fmt.Printf("%s already exists\n", path)
filename = Prompt("filename")
path = filepath.Join(config.SrcDir, filename)

View file

@ -31,9 +31,10 @@ type Config struct {
Lang string
HighlightTheme string
Minify bool
LiveReload bool
LinkStatic bool
Minify bool
LiveReload bool
LinkStatic bool
IncludeDrafts bool
ServerHost string
ServerPort int
@ -61,6 +62,7 @@ func Load(rootDir string) (*Config, error) {
Minify: true,
LiveReload: false,
LinkStatic: false,
IncludeDrafts: false,
pageDefaults: map[string]interface{}{},
}
@ -113,6 +115,7 @@ func LoadDev(rootDir string, host string, port int, reload bool) (*Config, error
config.LiveReload = reload
config.Minify = false
config.LinkStatic = true
config.IncludeDrafts = true
config.SiteUrl = fmt.Sprintf("http://%s:%d", config.ServerHost, config.ServerPort)
return config, nil

View file

@ -13,6 +13,7 @@ So far you've seen how to [[file:jorge-init][start a project]], [[file:jorge-ser
#+begin_src console
$ jorge build
skipping draft target/blog/my-own-blog-post.org
wrote target/2024-02-23-another-kind-of-post.html
wrote target/blog/my-own-blog-post.html
wrote target/blog/goodbye-markdown.html
@ -26,6 +27,7 @@ wrote target/blog/index.html
Just like ~jorge serve~ did before, ~jorge build~ scans your ~src/~ directory and renders its files into ~target/~, but with a few differences:
- Templates flagged as drafts in their front matter are excluded.
- Static files are copied over to ~target/~ instead of just linked.
- The ~url~ from your ~config.yml~ is used as the root when rendering absolute urls (instead of the ~http://localhost:4001~ used when serving locally).
- The HTML, XML, CSS and JavaScript files are minified.

View file

@ -44,6 +44,7 @@ date: 2024-02-23 11:45:30
layout: post
lang: en
tags: []
draft: true
---
#+OPTIONS: toc:nil num:nil
#+LANGUAGE: en
@ -51,13 +52,14 @@ tags: []
Let's look at what the command did for us:
{% raw %}
| ~src/blog/my-own-blog-post.org~ | The filename, a URL-friendly version of the post title (a "slug"), such that the post will be served at ~/blog/my-own-blog-post~ |
| ~title: My own blog post~ | The title we passed to jorge. This will be available to templates as ~{{page.title}}~ and will be used by the default post layout to render the header of the page. |
| ~date: 2024-02-23 11:45:30~ | The date this post was created. It will affect the position it shows up in in ~{{site.posts}}~ |
| ~layout: post~ | The rendered HTML of this template will be embedded as the ~{{contents}}~ of the layout defined in ~layouts/post.html~. |
| ~lang: en~ | The language code for the post. This is used by some of the default templates, for instance, to determine how to hyphenate the post content. |
| ~tags: []~ | The post tags, initially empty. The keywords in this list will determine which keys of the ~{{site.tags}}~ map this post will be associated with.
| ~#+OPTIONS: toc:nil num:nil~, ~#+LANGUAGE: en~ | Some default org mode options, to skip the table of contents and define the post language. |
| ~src/blog/my-own-blog-post.org~ | The filename, a URL-friendly version of the post title (a "slug"), such that the post will be served at ~/blog/my-own-blog-post~ |
| ~title: My own blog post~ | The title we passed to jorge. This will be available to templates as ~{{page.title}}~ and will be used by the default post layout to render the header of the page. |
| ~date: 2024-02-23 11:45:30~ | The date this post was created. It will affect the position it shows up in in ~{{site.posts}}~ |
| ~layout: post~ | The rendered HTML of this template will be embedded as the ~{{contents}}~ of the layout defined in ~layouts/post.html~. |
| ~lang: en~ | The language code for the post. This is used by some of the default templates, for instance, to determine how to hyphenate the post content. |
| ~tags: []~ | The post tags, initially empty. The keywords in this list will determine which keys of the ~{{site.tags}}~ map this post will be associated with. |
| ~draft: true~ | By default, posts created with ~jorge post~ are marked as drafts. Drafts are included in the site served locally but excluded from the production build. Remove this flag once your post is ready.
| ~#+OPTIONS: toc:nil num:nil~, ~#+LANGUAGE: en~ | Some default org mode options, to skip the table of contents and define the post language. |
{% endraw %}
With ~jorge serve~ running, you can start filling in some content on this new post and see it show up in the browser at http://localhost:4001/blog/my-own-blog-post.

View file

@ -99,8 +99,13 @@ func Parse(engine *Engine, path string) (*Template, error) {
return &templ, nil
}
// Return the extension of this template's source file.
func (templ Template) SrcExt() string {
return filepath.Ext(templ.SrcPath)
}
// Return the extension for the output format of this template
func (templ Template) Ext() string {
func (templ Template) TargetExt() string {
ext := filepath.Ext(templ.SrcPath)
if ext == ".org" || ext == ".md" {
return ".html"
@ -108,6 +113,18 @@ func (templ Template) Ext() string {
return ext
}
func (templ Template) IsDraft() bool {
if draft, ok := templ.Metadata["draft"]; ok {
return draft.(bool)
}
return false
}
func (templ Template) IsPost() bool {
_, ok := templ.Metadata["date"]
return ok
}
// Renders the liquid template with the given context as bindings.
// If the template source is org or md, convert them to html after the
// liquid rendering.
@ -118,9 +135,7 @@ func (templ Template) Render(context map[string]interface{}, hlTheme string) ([]
return nil, err
}
ext := filepath.Ext(templ.SrcPath)
if ext == ".org" {
if templ.SrcExt() == ".org" {
// org-mode rendering
doc := org.New().Parse(bytes.NewReader(content), templ.SrcPath)
htmlWriter := org.NewHTMLWriter()
@ -134,7 +149,7 @@ func (templ Template) Render(context map[string]interface{}, hlTheme string) ([]
return nil, err
}
content = []byte(contentStr)
} else if ext == ".md" {
} else if templ.SrcExt() == ".md" {
// markdown rendering
var buf bytes.Buffer
md := goldmark.New(goldmark.WithExtensions(

View file

@ -120,7 +120,7 @@ func (site *Site) loadDataFiles() error {
}
func (site *Site) loadTemplates() error {
if _, err := os.Stat(site.Config.SrcDir); os.IsNotExist(err) {
if _, err := os.Stat(site.Config.SrcDir); err != nil {
return fmt.Errorf("missing src directory")
}
@ -135,37 +135,42 @@ func (site *Site) loadTemplates() error {
// set site related (?) metadata. Not sure if this should go elsewhere
relPath, _ := filepath.Rel(site.Config.SrcDir, path)
srcPath, _ := filepath.Rel(site.Config.RootDir, path)
relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath)) + templ.Ext()
relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath)) + templ.TargetExt()
templ.Metadata["src_path"] = srcPath
templ.Metadata["path"] = relPath
templ.Metadata["url"] = "/" + strings.TrimSuffix(strings.TrimSuffix(relPath, "index.html"), ".html")
templ.Metadata["dir"] = "/" + filepath.Dir(relPath)
// posts are templates that can be chronologically sorted --that have a date.
// the rest are pages.
if _, ok := templ.Metadata["date"]; ok {
// if drafts are disabled, exclude from posts, page and tags indexes, but not from site.templates
// we want to explicitly exclude the template from the target, rather than treating it as a non template file
if !templ.IsDraft() || site.Config.IncludeDrafts {
// posts are templates that can be chronologically sorted --that have a date.
// the rest are pages.
if templ.IsPost() {
// NOTE: getting the excerpt if not set at the front matter requires rendering the template
// which could be too onerous for this stage. Consider postponing setting this and/or caching the
// template render result
templ.Metadata["excerpt"] = getExcerpt(templ)
site.posts = append(site.posts, templ.Metadata)
// NOTE: getting the excerpt if not set at the front matter requires rendering the template
// which could be too onerous for this stage. Consider postponing setting this and/or caching the
// template render result
templ.Metadata["excerpt"] = getExcerpt(templ)
site.posts = append(site.posts, templ.Metadata)
// also add to tags index
if tags, ok := templ.Metadata["tags"]; ok {
for _, tag := range tags.([]interface{}) {
tag := tag.(string)
site.tags[tag] = append(site.tags[tag], templ.Metadata)
// also add to tags index
if tags, ok := templ.Metadata["tags"]; ok {
for _, tag := range tags.([]interface{}) {
tag := tag.(string)
site.tags[tag] = append(site.tags[tag], templ.Metadata)
}
}
} else {
// the index pages should be skipped from the page directory
filename := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
if filename != "index" {
site.pages = append(site.pages, templ.Metadata)
}
}
} else {
// the index pages should be skipped from the page directory
filename := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
if filename != "index" {
site.pages = append(site.pages, templ.Metadata)
}
}
site.templates[path] = templ
}
return nil
@ -256,12 +261,17 @@ func (site *Site) buildFile(path string) error {
defer srcFile.Close()
contentReader = srcFile
} else {
if templ.IsDraft() && !site.Config.IncludeDrafts {
fmt.Println("skipping draft", targetPath)
return nil
}
content, err := site.render(templ)
if err != nil {
return err
}
targetPath = strings.TrimSuffix(targetPath, filepath.Ext(targetPath)) + templ.Ext()
targetPath = strings.TrimSuffix(targetPath, filepath.Ext(targetPath)) + templ.TargetExt()
contentReader = bytes.NewReader(content)
}
@ -357,7 +367,7 @@ func getExcerpt(templ *markup.Template) string {
}
// if we don't expect this to render to html don't bother parsing it
if templ.Ext() != ".html" {
if templ.TargetExt() != ".html" {
return ""
}

View file

@ -356,6 +356,166 @@ func TestRenderDataFile(t *testing.T) {
</ul>`)
}
func TestBuildTarget(t *testing.T) {
config := newProject()
defer os.RemoveAll(config.RootDir)
// add base layout
content := `---
---
<html>
<head><title>{{page.title}}</title></head>
<body>
{{content}}
</body>
</html>`
newFile(config.LayoutsDir, "base.html", content)
// add org post
content = `---
layout: base
title: p1 - hello world!
date: 2024-01-01
---
* Hello world!`
newFile(config.SrcDir, "p1.org", content)
// add markdown post
content = `---
layout: base
title: p2 - goodbye world!
date: 2024-01-02
---
# Goodbye world!`
newFile(config.SrcDir, "p2.md", content)
// add index page
content = `---
layout: base
---
<ul>{% for post in site.posts %}
<li>{{post.title}}</li>{%endfor%}
</ul>`
newFile(config.SrcDir, "index.html", content)
// build site
site, err := Load(*config)
assertEqual(t, err, nil)
err = site.Build()
assertEqual(t, err, nil)
// test target files generated
_, err = os.Stat(filepath.Join(config.TargetDir, "p1.html"))
assertEqual(t, err, nil)
_, err = os.Stat(filepath.Join(config.TargetDir, "p2.html"))
assertEqual(t, err, nil)
// test index includes p1 and p2
output, err := os.ReadFile(filepath.Join(config.TargetDir, "index.html"))
assertEqual(t, err, nil)
assertEqual(t, string(output), `<html><head><title></title></head>
<body>
<ul>
<li>p2 - goodbye world!</li>
<li>p1 - hello world!</li>
</ul>
</body></html>`)
}
func TestBuildWithDrafts(t *testing.T) {
config := newProject()
defer os.RemoveAll(config.RootDir)
// add base layout
content := `---
---
<html>
<head><title>{{page.title}}</title></head>
<body>
{{content}}
</body>
</html>`
newFile(config.LayoutsDir, "base.html", content)
// add org post
content = `---
layout: base
title: p1 - hello world!
date: 2024-01-01
---
* Hello world!`
newFile(config.SrcDir, "p1.org", content)
// add markdown post, make it draft
content = `---
layout: base
title: p2 - goodbye world!
date: 2024-01-02
draft: true
---
# Goodbye world!`
newFile(config.SrcDir, "p2.md", content)
// add index page
content = `---
layout: base
---
<ul>{% for post in site.posts %}
<li>{{post.title}}</li>{%endfor%}
</ul>`
newFile(config.SrcDir, "index.html", content)
// build site with drafts
config.IncludeDrafts = true
site, err := Load(*config)
assertEqual(t, err, nil)
err = site.Build()
assertEqual(t, err, nil)
// test target files generated
_, err = os.Stat(filepath.Join(config.TargetDir, "p1.html"))
assertEqual(t, err, nil)
_, err = os.Stat(filepath.Join(config.TargetDir, "p2.html"))
assertEqual(t, err, nil)
// test index includes p1 and p2
output, err := os.ReadFile(filepath.Join(config.TargetDir, "index.html"))
assertEqual(t, err, nil)
assertEqual(t, string(output), `<html><head><title></title></head>
<body>
<ul>
<li>p2 - goodbye world!</li>
<li>p1 - hello world!</li>
</ul>
</body></html>`)
// build site WITHOUT drafts
config.IncludeDrafts = false
site, err = Load(*config)
assertEqual(t, err, nil)
err = site.Build()
assertEqual(t, err, nil)
// test only non drafts generated
_, err = os.Stat(filepath.Join(config.TargetDir, "p1.html"))
assertEqual(t, err, nil)
_, err = os.Stat(filepath.Join(config.TargetDir, "p2.html"))
assert(t, os.IsNotExist(err))
// test index includes p1 but NOT p2
output, err = os.ReadFile(filepath.Join(config.TargetDir, "index.html"))
assertEqual(t, err, nil)
assertEqual(t, string(output), `<html><head><title></title></head>
<body>
<ul>
<li>p1 - hello world!</li>
</ul>
</body></html>`)
}
// ------ HELPERS --------
func newProject() *config.Config {