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 layout: post
lang: en lang: en
tags: [] tags: []
draft: true
--- ---
#+OPTIONS: toc:nil num:nil #+OPTIONS: toc:nil num:nil
#+LANGUAGE: en #+LANGUAGE: en
@ -83,6 +84,12 @@ this is my *first* post.
EOF 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`: Finally, you can render a minified version of your site with `jorge build`:
``` ```

View file

@ -19,6 +19,7 @@ date: %s
layout: post layout: post
lang: %s lang: %s
tags: [] 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 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) fmt.Printf("%s already exists\n", path)
filename = Prompt("filename") filename = Prompt("filename")
path = filepath.Join(config.SrcDir, filename) path = filepath.Join(config.SrcDir, filename)

View file

@ -31,9 +31,10 @@ type Config struct {
Lang string Lang string
HighlightTheme string HighlightTheme string
Minify bool Minify bool
LiveReload bool LiveReload bool
LinkStatic bool LinkStatic bool
IncludeDrafts bool
ServerHost string ServerHost string
ServerPort int ServerPort int
@ -61,6 +62,7 @@ func Load(rootDir string) (*Config, error) {
Minify: true, Minify: true,
LiveReload: false, LiveReload: false,
LinkStatic: false, LinkStatic: false,
IncludeDrafts: false,
pageDefaults: map[string]interface{}{}, pageDefaults: map[string]interface{}{},
} }
@ -113,6 +115,7 @@ func LoadDev(rootDir string, host string, port int, reload bool) (*Config, error
config.LiveReload = reload config.LiveReload = reload
config.Minify = false config.Minify = false
config.LinkStatic = true config.LinkStatic = true
config.IncludeDrafts = true
config.SiteUrl = fmt.Sprintf("http://%s:%d", config.ServerHost, config.ServerPort) config.SiteUrl = fmt.Sprintf("http://%s:%d", config.ServerHost, config.ServerPort)
return config, nil 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 #+begin_src console
$ jorge build $ jorge build
skipping draft target/blog/my-own-blog-post.org
wrote target/2024-02-23-another-kind-of-post.html wrote target/2024-02-23-another-kind-of-post.html
wrote target/blog/my-own-blog-post.html wrote target/blog/my-own-blog-post.html
wrote target/blog/goodbye-markdown.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: 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. - 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 ~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. - The HTML, XML, CSS and JavaScript files are minified.

View file

@ -44,6 +44,7 @@ date: 2024-02-23 11:45:30
layout: post layout: post
lang: en lang: en
tags: [] tags: []
draft: true
--- ---
#+OPTIONS: toc:nil num:nil #+OPTIONS: toc:nil num:nil
#+LANGUAGE: en #+LANGUAGE: en
@ -51,13 +52,14 @@ tags: []
Let's look at what the command did for us: Let's look at what the command did for us:
{% raw %} {% 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~ | | ~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. | | ~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}}~ | | ~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~. | | ~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. | | ~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. | ~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. | | ~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 %} {% 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. 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 &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 // 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) ext := filepath.Ext(templ.SrcPath)
if ext == ".org" || ext == ".md" { if ext == ".org" || ext == ".md" {
return ".html" return ".html"
@ -108,6 +113,18 @@ func (templ Template) Ext() string {
return ext 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. // Renders the liquid template with the given context as bindings.
// If the template source is org or md, convert them to html after the // If the template source is org or md, convert them to html after the
// liquid rendering. // liquid rendering.
@ -118,9 +135,7 @@ func (templ Template) Render(context map[string]interface{}, hlTheme string) ([]
return nil, err return nil, err
} }
ext := filepath.Ext(templ.SrcPath) if templ.SrcExt() == ".org" {
if ext == ".org" {
// org-mode rendering // org-mode rendering
doc := org.New().Parse(bytes.NewReader(content), templ.SrcPath) doc := org.New().Parse(bytes.NewReader(content), templ.SrcPath)
htmlWriter := org.NewHTMLWriter() htmlWriter := org.NewHTMLWriter()
@ -134,7 +149,7 @@ func (templ Template) Render(context map[string]interface{}, hlTheme string) ([]
return nil, err return nil, err
} }
content = []byte(contentStr) content = []byte(contentStr)
} else if ext == ".md" { } else if templ.SrcExt() == ".md" {
// markdown rendering // markdown rendering
var buf bytes.Buffer var buf bytes.Buffer
md := goldmark.New(goldmark.WithExtensions( md := goldmark.New(goldmark.WithExtensions(

View file

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

View file

@ -356,6 +356,166 @@ func TestRenderDataFile(t *testing.T) {
</ul>`) </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 -------- // ------ HELPERS --------
func newProject() *config.Config { func newProject() *config.Config {