forb

Forb is static blog generator inspired by jekyll
git clone https://noulin.net/git/forb.git
Log | Files | Refs | README | LICENSE

commit 3f618f52abd4b338ffb7b987de12038f8d93502b
parent 80c29d144cbb6ef2f34c5475f4cd296001a6e941
Author: Remy Noulin <loader2x@gmail.com>
Date:   Mon,  3 Aug 2020 21:43:26 +0200

add update command to republish a post, change blog generator to support first publish date and 'update date'

README.md |  23 +++++-
forb.c    | 278 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
forb.h    |   7 ++
3 files changed, 283 insertions(+), 25 deletions(-)

Diffstat:
MREADME.md | 23+++++++++++++++++++----
Mforb.c | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Aforb.h | 7+++++++
3 files changed, 283 insertions(+), 25 deletions(-)

diff --git a/README.md b/README.md @@ -21,11 +21,26 @@ The site configuration is located in `_config.yml`. The default `_config.yml` is # Posts -Add your posts in the `_posts` directory, with filenames in the format: -`YYYY-MM-DD-TITLE.markdown` +To create a post, run: +``` +forb new post title +``` + +`forb new title` creates a post template in _draft, edit the draft and when ready, run: +``` +forb publish _draft/title.markdown +``` -For example: -`2020-06-09-getting-started.markdown` +# Update already published post + +To generate the blog, run `forb`, after this the posts are moved from the _posts directory to the _published directory. + +To update a post, edit the text in the _published directory, then run: +``` +forb update _published/title.markdown +# generate +forb +``` # About markdown diff --git a/forb.c b/forb.c @@ -32,7 +32,6 @@ #define siteDir "_site" #define mainCss "css/main.css" #define imagesDir "images" - #define content "{{ content }}" /* enable/disable logging */ @@ -43,6 +42,8 @@ void help(void); void publish(const char *path); +void update(const char *path); + bool readFileWithFrontMatter(char *filename, smallJsont *kv, smallArrayt *lines); bool generateAPageOrAPost(char *filename, smallJsont *cfg, smallArrayt *postsFeed); @@ -55,6 +56,37 @@ int main(int ARGC, char** ARGV) { setLogSymbols(LOG_UTF8); //disableLibsheepyErrorLogs; + // steps + // process command line arguments + // arg new + // create directory structure when config file is not found + // else create a draft + // arg help + // print help + // arg publish + // copy draft to post dir, the date field is updated + // arg update + // copy published post to post dir, the date field is updated + // when there are no arguments, generate the blog + // check inputs + // load config + // copy main css + // copy images + // generate index + // read posts + // list all md files in _posts and _published + // generate html code for each post for index.html + // create configuation for index + // save index + // generate about page + // generate posts + // collect html code for 10 last post for feed.xml + // generate feed.xml + // clean tmp files + // list all md files in _posts + // move posts from _posts to _published + // add first publish date if needed + // read arguments #define checkInput(path) procbegin\ if (not isPath(path)) {\ @@ -79,6 +111,7 @@ int main(int ARGC, char** ARGV) { XSuccess; } elif (eqG(ARGV[1], "new")) { + // create a draft checkInput(draftsDir); if (ARGC < 3) { logE(BLD"Too few arguments"RST", post title missing.\n" @@ -86,6 +119,7 @@ int main(int ARGC, char** ARGV) { ); XFailure; } + // create post title and filename cleanListP(args) = dupG(ARGV); delElemG(&args, 1); delElemG(&args, 0); @@ -100,6 +134,7 @@ int main(int ARGC, char** ARGV) { } } lowerG(&draftName); + uniqG(&draftName, '-'); prependG(&draftName, draftsDir"/"); pushG(&draftName, ".markdown"); //lv(draftName); @@ -114,6 +149,7 @@ int main(int ARGC, char** ARGV) { XSuccess; } elif (eqG(ARGV[1], "publish")) { + // copy draft to post dir if (ARGC < 3) { logE("publish failed. Missing path, usage:\n" "forb publish PATH" @@ -132,6 +168,26 @@ int main(int ARGC, char** ARGV) { } XSuccess; } + elif (eqG(ARGV[1], "update")) { + // copy published post to post dir, the date field is updated + if (ARGC < 3) { + logE("update failed. Missing path, usage:\n" + "forb update PATH" + ); + XFailure; + } + rangeFrom(i, 2, ARGC) { + if (not isPath(ARGV[i])) { + logE("Path %d '%s' not found", i, ARGV[i]); + } + else { + checkInput(publishedDir); + checkInput(PostsDir); + update(ARGV[i]); + } + } + XSuccess; + } else { logE("Command "BLD"'%s'"RST" not found or invalid parameters.\n\n" , ARGV[1]); @@ -140,6 +196,7 @@ int main(int ARGC, char** ARGV) { } } + // when there are no arguments, generate the blog // check inputs checkInput(configFile); @@ -173,7 +230,7 @@ int main(int ARGC, char** ARGV) { readFileWithFrontMatter(indexFile, indexCfg, indexf); // read posts - // list all md files in _posts + // list all md files in _posts and _published cleanSmallArrayP(postsDir) = readDirG(rtSmallArrayt, PostsDir); iter(postsDir, L) { castS(l,L); @@ -182,6 +239,16 @@ int main(int ARGC, char** ARGV) { } } //lv(postsDir); + cleanSmallArrayP(pubDir) = readDirG(rtSmallArrayt, publishedDir); + iter(pubDir, L) { + castS(l,L); + if (not endsWithG(l, ".markdown")) { + delElemG(pubDir, iI(pubDir)); + } + } + appendNFreeG(postsDir, dupG(pubDir)); + compactG(postsDir); + sortG(postsDir); // generate html code for each post for index.html cleanAllocateSmallArray(postsIndex); @@ -189,17 +256,38 @@ int main(int ARGC, char** ARGV) { castS(l,L); cleanCharP(postHtmlFile) = copyRngG(ssGet(l), 11, 0); replaceG(&postHtmlFile, ".markdown", ".html", 1); - prependG(l, PostsDir); - setPG(postsDir, iterIndexG(postsDir), l); + // determine if file is in _posts or _published + if (hasG(pubDir, l)) { + // file is in _published + prependG(l, publishedDir"/"); + } + else { + prependG(l, PostsDir"/"); + } + setPG(postsDir, iI(postsDir), l); cleanAllocateSmallJson(postCfg); cleanAllocateSmallArray(postf); readFileWithFrontMatter(ssGet(l), postCfg, postf); - cleanCharP(postDate) = copyRngG($(postCfg, "date"), 0, 10); + // select first publish date for html path: root/category/YY/MM/DD/postName.html + cleanCharP(postDate) = hasG(postCfg, "firstPublishDate") ? copyRngG($(postCfg, "firstPublishDate"), 0, 10) : copyRngG($(postCfg, "date"), 0, 10); replaceG(&postDate, "-", "/", 0); cleanSmallArrayP(postUrlA) = createSA($(cfg, "baseurl"), $(postCfg, "categories"), postDate, postHtmlFile); cleanCharP(postUrl) = joinSG(postUrlA, "/"); - $(postCfg, "date")[10] = 0; + // char *date is the visible date string: 'date' when the post is published for the first time, 'firstPublishDate update on date' when the post has been updated + cleanCharP(date); + if (hasG(postCfg, "firstPublishDate")) { + if (eqG($(postCfg, "date"), $(postCfg, "firstPublishDate"))) goto onlyPublishDate; + // there is an update date + $(postCfg, "date")[10] = 0; + $(postCfg, "firstPublishDate")[10] = 0; + date = catS($(postCfg, "firstPublishDate"), " updated on ", $(postCfg, "date")); + } + else { + onlyPublishDate: + $(postCfg, "date")[10] = 0; + date = dupG($(postCfg, "date")); + } cleanCharP(s) = formatS("<li>\n" " <span class=\"post-meta\">%s</span>\n" "\n" @@ -207,7 +295,7 @@ int main(int ARGC, char** ARGV) { " <a class=\"post-link\" href=\"%s\">%s</a>\n" " </h2>\n" "</li>\n" - ,$(postCfg, "date"), postUrl, $(postCfg, "title")); + ,date, postUrl, $(postCfg, "title")); pushG(postsIndex, s); } //cleanCharP(postsIndexS) = joinSG(postsIndex, "\n"); @@ -321,6 +409,62 @@ int main(int ARGC, char** ARGV) { // clean tmp files if (isPath("tmp.html")) rmAll("tmp.html"); if (isPath("tmp.md")) rmAll("tmp.md"); + + + // list all md files in _posts + cleanSmallArrayP(postFiles) = readDirG(rtSmallArrayt, PostsDir); + iter(postFiles, L) { + castS(l,L); + if (not endsWithG(l, ".markdown")) { + delElemG(postFiles, iterIndexG(postFiles)); + } + } + //lv(postFiles); + + // move posts from _posts to _published + // add first publish date if needed + iter(postFiles, L) { + castS(p,L); + cleanCharP(postPath) = catS(PostsDir"/", ssGet(p)); + cleanAllocateSmallArray(post); + readFileG(post, postPath); + + char *status = "search front matter"; + i32 dateIdx = -1; + iter(post, L) { + castS(l,L); + if (not eqG(status, "search front matter") and startsWithG(l, "firstPublishDate:")) + // found first publish date. Stop. + break; + if (not eqG(status, "search front matter") and eqG(l, "---")) { + if (dateIdx == -1) { + logE("Date not found in front matter, post is %m", p); + break; + } + cleanCharP(pDate) = replaceG($(post, dateIdx), "date:", "firstPublishDate:", 1); + injectG(post, iI(post), pDate); + break; + } + if (eqG(status, "date") and startsWithG(l, "date:")) { + dateIdx = iI(post); + status = "publishDate"; + } + if (eqG(status, "search front matter") and eqG(l, "---")) { + status = "date"; + } + } + + // move post to _published directory + cleanCharP(pubPath) = catS(publishedDir"/", ssGet(p)); + if (not writeFileG(post, pubPath)) { + logE("Could not write '%s', post is: %m", pubPath, p); + continue; + } + if (not rmAllG(postPath)) { + logE("Could not remove: %s", postPath); + continue; + } + } } void help(void) { @@ -335,6 +479,7 @@ void help(void) { ); } + /** * publish a post in _drafts to _posts */ @@ -367,8 +512,7 @@ void publish(const char *path) { //lv(path); // create destination filename - char *filename = basename(path); - cleanCharP(dest) = catS(PostsDir, "/", date, "-", filename); + cleanCharP(dest) = catS(PostsDir"/", date, "-", basename(path)); //lv(dest); // create date string for front matter @@ -414,6 +558,77 @@ void publish(const char *path) { /** + * update a post in _published to _posts + */ +void update(const char *path) { + // steps + // check extension + // read post + // create destination filename + // create date string for front matter + // change date string in front matter + // failed to find front matter or date + // save posts directory + // remove path + + // check extension + if (not endsWithG(path, ".markdown")) { + logE("'%s' doesn't have the markdown extension. Stop."); + XFailure; + } + + // read post + cleanAllocateSmallArray(post); + readFileG(post, path); + + // create destination filename + cleanCharP(dest) = catS(PostsDir"/", basename(path)); + //lv(dest); + + // create date string for front matter + cleanCharP(date) = getCurrentDateYMD(); + date[10] = ' '; + prependG(&date, "date: "); + //lv(date); + + // change date string in front matter + char *status = "search front matter"; + iter(post, L) { + castS(l, L); + if (eqG(status, "stop at date") and startsWithG(l, "date:")) { + setG(post, iI(post), date); + goto publishPost; + } + if (eqG(status, "stop at date") and eqG(l, "---")) { + logE("date not found in front matter, path is: %s", path); + ret; + } + if (eqG(status, "search front matter") and eqG(l, "---")) { + status = "stop at date"; + } + } + + // failed to find front matter or date + logE("front matter or date not found, path is %s", path); + ret; + + publishPost: + // save posts directory + if (not writeFileG(post, dest)) { + logE("Could not write '%s', path is: %s", dest, path); + ret; + } + + // remove path + if (not rmAllG(path)) { + logE("Could not remove: %s", path); + } + + logP("Update already published post in %s", dest); +} + + +/** * read file filename with front matter and text (any type of text) * * \return @@ -481,7 +696,11 @@ bool readFileWithFrontMatter(char *filename, smallJsont *kv, smallArrayt *lines) * The url format is "/category/YYYY/MM/DD/filename.html" */ char *postToUrl(char *filename) { - cleanCharP(fn) = catS(PostsDir,filename,".markdown"); + cleanCharP(fn) = catS(PostsDir"/",filename,".markdown"); + if (not isPath(fn)) { + free(fn); + fn = catS(publishedDir"/",filename,".markdown"); + } cleanAllocateSmallArray(pf); cleanAllocateSmallJson(pCfg); @@ -490,7 +709,8 @@ char *postToUrl(char *filename) { cleanCharP(postHtmlFile) = copyRngG(fn, 18, 0); replaceG(&postHtmlFile, ".markdown", ".html", 1); - cleanCharP(postDate) = copyRngG($(pCfg, "date"), 0, 10); + // select first publish date for html path: root/category/YY/MM/DD/postName.html + cleanCharP(postDate) = hasG(pCfg, "firstPublishDate") ? copyRngG($(pCfg, "firstPublishDate"), 0, 10) : copyRngG($(pCfg, "date"), 0, 10); replaceG(&postDate, "-", "/", 0); char *pUrl = catS("/",$(pCfg,"categories"),"/",postDate,"/",postHtmlFile); @@ -511,6 +731,7 @@ bool generateAPageOrAPost(char *filename, smallJsont *cfg, smallArrayt *postsFee // add layout and html code in sub layout // TODO list pages on first line of all pages // configure destination path, post url, date and description + // detect if generating a post or a page to select path and parameters // replace keys with values // generate feed // write post html page @@ -849,22 +1070,37 @@ bool generateAPageOrAPost(char *filename, smallJsont *cfg, smallArrayt *postsFee cleanAllocateSmallJson(aboutCfg); readFileWithFrontMatter("about.md", aboutCfg, about); - char *sitePages = catS("<a class=\"page-link\" href=\"", $(cfg, "baseurl"), $(aboutCfg, "permalink"), "\">", $(aboutCfg, "title"), "</a>"); + char *sitePages = catS("<a class=\"page-link\" href=\"", $(cfg, "baseurl"), $(aboutCfg, "permalink"), "\">", $(aboutCfg, "title"), "</a>"); // configure destination path, post url, date and description - cleanCharP(dst) = null; - char *pUrl = null; + cleanCharP(dst) = null; + char *pUrl = null; baset *description = null; - if (startsWithG(filename, PostsDir)) { - cleanCharP(postHtmlFile) = copyRngG(filename, 18, 0); + // char *date is the visible date string: 'date' when the post is published for the first time, 'firstPublishDate update on date' when the post has been updated + cleanCharP(date) = null; + // detect if generating a post or a page to select path and parameters + if (startsWithG(filename, PostsDir"/") or startsWithG(filename, publishedDir"/")) { + cleanCharP(postHtmlFile) = startsWithG(filename, PostsDir"/") ? copyRngG(filename, 18, 0) : copyRngG(filename, 22, 0); replaceG(&postHtmlFile, ".markdown", ".html", 1); - cleanCharP(postDate) = copyRngG($(pCfg, "date"), 0, 10); + // select first publish date for html path: root/category/YY/MM/DD/postName.html + cleanCharP(postDate) = hasG(pCfg, "firstPublishDate") ? copyRngG($(pCfg, "firstPublishDate"), 0, 10) : copyRngG($(pCfg, "date"), 0, 10); replaceG(&postDate, "-", "/", 0); dst = catS(siteDir,"/",$(pCfg, "categories"),"/",postDate,"/",postHtmlFile); pUrl = catS($(cfg, "url"),$(cfg, "baseurl"),"/", $(pCfg,"categories"),"/",postDate,"/",postHtmlFile); - $(pCfg, "date")[10] = 0; + if (hasG(pCfg, "firstPublishDate")) { + if (eqG($(pCfg, "date"), $(pCfg, "firstPublishDate"))) goto onlyPublishDate; + // there is an update date + $(pCfg, "date")[10] = 0; + $(pCfg, "firstPublishDate")[10] = 0; + date = catS($(pCfg, "firstPublishDate"), " updated on ", $(pCfg, "date")); + } + else { + onlyPublishDate: + $(pCfg, "date")[10] = 0; + date = dupG($(pCfg, "date")); + } iter(pf, L) { castS(l,L); @@ -897,8 +1133,8 @@ bool generateAPageOrAPost(char *filename, smallJsont *cfg, smallArrayt *postsFee setNFreeG(pageValues, "_\%site.email", getNDupO(cfg, "email")); setNFreeG(pageValues, "_\%site.github_username", getNDupO(cfg, "github_username")); setNFreeG(pageValues, "_\%site.twitter_username", getNDupO(cfg, "twitter_username")); - if (startsWithG(filename, PostsDir)) { - setG(pageValues, "_\%date", $(pCfg, "date")); + if (startsWithG(filename, PostsDir"/") or startsWithG(filename, publishedDir"/")) { + setG(pageValues, "_\%date", date); } //lv(pageValues); @@ -920,7 +1156,7 @@ bool generateAPageOrAPost(char *filename, smallJsont *cfg, smallArrayt *postsFee " <guid isPermaLink=\"true\">%s</guid>\n" " <category>%s</category>\n" "</item>\n", - $(pCfg, "title"), postContent, $(pCfg, "date"), $(pageValues, "_\%url"), $(pageValues, "_\%url"), + $(pCfg, "title"), postContent, date, $(pageValues, "_\%url"), $(pageValues, "_\%url"), $(pCfg, "categories"))); } diff --git a/forb.h b/forb.h @@ -0,0 +1,7 @@ + +#define draftTemplate "---\n"\ + "layout: post\n"\ + "title: \"$TITLE\"\n"\ + "categories: default\n"\ + "---\n"\ + "Set a relevant category for your post above and write your post text here (after the YML front matter)."