From 6f27afacf748b24bbd31f79747edb97c611b6bf9 Mon Sep 17 00:00:00 2001 From: Facundo Olano Date: Tue, 27 Feb 2024 12:24:45 -0300 Subject: [PATCH] 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 --- README.md | 7 ++ commands/post.go | 3 +- config/config.go | 9 +- docs/src/tutorial/jorge-build.org | 2 + docs/src/tutorial/jorge-post.org | 16 +-- markup/templates.go | 25 ++++- site/site.go | 58 ++++++----- site/site_test.go | 160 ++++++++++++++++++++++++++++++ 8 files changed, 240 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index a27cc58..630f02a 100644 --- a/README.md +++ b/README.md @@ -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`: ``` diff --git a/commands/post.go b/commands/post.go index e8f9d1e..b6d58a8 100644 --- a/commands/post.go +++ b/commands/post.go @@ -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) diff --git a/config/config.go b/config/config.go index ae46a65..f9c0e09 100644 --- a/config/config.go +++ b/config/config.go @@ -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 diff --git a/docs/src/tutorial/jorge-build.org b/docs/src/tutorial/jorge-build.org index 493662e..1ac51fa 100755 --- a/docs/src/tutorial/jorge-build.org +++ b/docs/src/tutorial/jorge-build.org @@ -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. diff --git a/docs/src/tutorial/jorge-post.org b/docs/src/tutorial/jorge-post.org index 6131024..0b5e858 100755 --- a/docs/src/tutorial/jorge-post.org +++ b/docs/src/tutorial/jorge-post.org @@ -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. diff --git a/markup/templates.go b/markup/templates.go index 04c7c3c..cfabe15 100644 --- a/markup/templates.go +++ b/markup/templates.go @@ -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( diff --git a/site/site.go b/site/site.go index 589a040..6a82e3c 100644 --- a/site/site.go +++ b/site/site.go @@ -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 "" } diff --git a/site/site_test.go b/site/site_test.go index a65da58..0b61065 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -356,6 +356,166 @@ func TestRenderDataFile(t *testing.T) { `) } +func TestBuildTarget(t *testing.T) { + config := newProject() + defer os.RemoveAll(config.RootDir) + + // add base layout + content := `--- +--- + +{{page.title}} + +{{content}} + +` + 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 +--- +` + 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), ` + + + +`) +} + +func TestBuildWithDrafts(t *testing.T) { + config := newProject() + defer os.RemoveAll(config.RootDir) + + // add base layout + content := `--- +--- + +{{page.title}} + +{{content}} + +` + 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 +--- +` + 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), ` + + + +`) + + // 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), ` + + + +`) +} + // ------ HELPERS -------- func newProject() *config.Config {