forb

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

forb.c (38494B)


      1 #! /usr/bin/env sheepy
      2 #include "libsheepyObject.h"
      3 #include "shpPackages/short/short.h"
      4 #include "shpPackages/preprocessor/preprocessor.h"
      5 #include "shpPackages/simpleTemplates/simpleTemplates.h"
      6 #include "forb.h"
      7 
      8 // forb new: copy templates to current directory
      9 // forb:     generate static blog in the _site directory
     10 
     11 // Steps
     12 // read arguments
     13 // check inputs
     14 // load config
     15 // copy main css
     16 // copy images
     17 // generate index
     18   // read posts
     19   // generate html code for each post for index.html
     20   // create configuation for index
     21 // generate about
     22 // generate posts
     23 // generate feed.xml
     24 // clean tmp files
     25 
     26 #define configFile "_config.yml"
     27 #define indexFile "index.html"
     28 #define layoutDir "_layouts"
     29 #define draftsDir "_drafts"
     30 #define PostsDir "_posts"
     31 #define publishedDir "_published"
     32 #define siteDir "_site"
     33 #define mainCss "css/main.css"
     34 #define imagesDir "images"
     35 #define content "{{ content }}"
     36 
     37 /* enable/disable logging */
     38 /* #undef pLog */
     39 /* #define pLog(...) */
     40 
     41 void help(void);
     42 
     43 void publish(const char *path);
     44 
     45 void update(const char *path);
     46 
     47 bool readFileWithFrontMatter(char *filename, smallJsont *kv, smallArrayt *lines);
     48 
     49 bool generateAPageOrAPost(char *filename, smallJsont *cfg, smallArrayt *postsFeed);
     50 
     51 int main(int ARGC, char** ARGV) {
     52 
     53   initLibsheepy(ARGV[0]);
     54   setLogMode(LOG_FUNC);
     55   //openProgLogFile();
     56   setLogSymbols(LOG_UTF8);
     57   //disableLibsheepyErrorLogs;
     58 
     59   // steps
     60   // process command line arguments
     61   // arg new
     62   //   create directory structure when config file is not found
     63   //   else create a draft
     64   // arg help
     65   //   print help
     66   // arg publish
     67   //   copy draft to post dir, the date field is updated
     68   // arg update
     69   //   copy published post to post dir, the date field is updated
     70   // when there are no arguments, generate the blog
     71   // check inputs
     72   // load config
     73   // copy main css
     74   // copy images
     75   // generate index
     76   //   read posts
     77   //   list all md files in _posts and _published
     78   //   generate html code for each post for index.html
     79   //   create configuation for index
     80   // save index
     81   // generate about page
     82   // generate posts
     83   //   collect html code for 10 last post for feed.xml
     84   // generate feed.xml
     85   // clean tmp files
     86   // list all md files in _posts
     87   // move posts from _posts to _published
     88   // add first publish date if needed
     89 
     90   // read arguments
     91   #define checkInput(path) procbegin\
     92     if (not isPath(path)) {\
     93       logE(BLD"%s not found."RST"\n"\
     94           , path);\
     95       help();\
     96       XFailure;\
     97     }\
     98     procend
     99 
    100   if (ARGC > 1) {
    101     if (eqG(ARGV[1], "new") and not isPath(configFile)) {
    102       // create directory structure when config file is not found
    103       bool a;
    104       if (a = isPath("about.md")) {
    105         if (a) logE("about.md already exists.");
    106         logE("Stop.");
    107         XFailure;
    108       }
    109       cleanCharP(progPath) = shDirname(getRealProgPath());
    110       logCommandf("cp -R %s/template/* .", progPath);
    111       XSuccess;
    112     }
    113     elif (eqG(ARGV[1], "new")) {
    114       // create a draft
    115       checkInput(draftsDir);
    116       if (ARGC < 3) {
    117         logE(BLD"Too few arguments"RST", post title missing.\n"
    118              "forb new "BLD"TITLE"RST"\n"
    119             );
    120         XFailure;
    121       }
    122       // create post title and filename
    123       cleanListP(args) = dupG(ARGV);
    124       delElemG(&args, 1);
    125       delElemG(&args, 0);
    126       cleanCharP(title) = joinG(args, ' ');
    127       uniqG(&title, ' ');
    128       //lv(title);
    129       cleanCharP(draftName) = dupG(title);
    130       char *tmp = draftName;
    131       while(*tmp++) {
    132         if (*tmp and not isalnum(*tmp)) {
    133           *tmp = '-';
    134         }
    135       }
    136       lowerG(&draftName);
    137       uniqG(&draftName, '-');
    138       prependG(&draftName, draftsDir"/");
    139       pushG(&draftName, ".markdown");
    140       //lv(draftName);
    141       cleanCharP(defaultDraft) = replaceG(draftTemplate, "$TITLE", title, 1);
    142       writeFileG(defaultDraft, draftName);
    143       logP(BLD GRN"Generated %s\n"RST
    144           "Open %s in your text editor and write your post.", draftName, draftName);
    145       XSuccess;
    146     }
    147     elif (eqG(ARGV[1], "-h") or eqG(ARGV[1], "--help") or eqG(ARGV[1], "help")) {
    148       help();
    149       XSuccess;
    150     }
    151     elif (eqG(ARGV[1], "publish")) {
    152       // copy draft to post dir
    153       if (ARGC < 3) {
    154         logE("publish failed. Missing path, usage:\n"
    155              "forb publish PATH"
    156              );
    157         XFailure;
    158       }
    159       rangeFrom(i, 2, ARGC) {
    160         if (not isPath(ARGV[i])) {
    161           logE("Path %d '%s' not found", i, ARGV[i]);
    162         }
    163         else {
    164           checkInput(draftsDir);
    165           checkInput(PostsDir);
    166           publish(ARGV[i]);
    167         }
    168       }
    169       XSuccess;
    170     }
    171     elif (eqG(ARGV[1], "update")) {
    172       // copy published post to post dir, the date field is updated
    173       if (ARGC < 3) {
    174         logE("update failed. Missing path, usage:\n"
    175              "forb update PATH"
    176              );
    177         XFailure;
    178       }
    179       rangeFrom(i, 2, ARGC) {
    180         if (not isPath(ARGV[i])) {
    181           logE("Path %d '%s' not found", i, ARGV[i]);
    182         }
    183         else {
    184           checkInput(publishedDir);
    185           checkInput(PostsDir);
    186           update(ARGV[i]);
    187         }
    188       }
    189       XSuccess;
    190     }
    191     else {
    192       logE("Command "BLD"'%s'"RST" not found or invalid parameters.\n\n"
    193            , ARGV[1]);
    194       help();
    195       XFailure;
    196     }
    197   }
    198 
    199   // when there are no arguments, generate the blog
    200 
    201   // check inputs
    202   checkInput(configFile);
    203   checkInput(indexFile);
    204   checkInput(layoutDir);
    205 
    206   // load config
    207   cleanAllocateSmallJson(cfg);
    208   readFileG(cfg, configFile);
    209 
    210   if (not isPath(siteDir)) {
    211     mkdirParents(siteDir);
    212   }
    213 
    214   // copy main css
    215   if (not isPath(siteDir"/css")) {
    216     mkdirParents(siteDir"/css");
    217   }
    218   copy(mainCss, siteDir"/"mainCss);
    219 
    220   // copy images
    221   if (not isPath(siteDir"/"imagesDir)) {
    222     mkdirParents(siteDir"/"imagesDir);
    223   }
    224   command("cp "imagesDir"/* "siteDir"/"imagesDir"/");
    225 
    226 
    227   // generate index
    228   cleanAllocateSmallArray(indexf);
    229   cleanAllocateSmallJson(indexCfg);
    230   readFileWithFrontMatter(indexFile, indexCfg, indexf);
    231 
    232     // read posts
    233     // list all md files in _posts and _published
    234   cleanSmallArrayP(postsDir) = readDirG(rtSmallArrayt, PostsDir);
    235   iter(postsDir, L) {
    236     castS(l,L);
    237     if (not endsWithG(l, ".markdown")) {
    238       delElemG(postsDir, iterIndexG(postsDir));
    239     }
    240   }
    241   //lv(postsDir);
    242   cleanSmallArrayP(pubDir)   = readDirG(rtSmallArrayt, publishedDir);
    243   iter(pubDir, L) {
    244     castS(l,L);
    245     if (not endsWithG(l, ".markdown")) {
    246       delElemG(pubDir, iI(pubDir));
    247     }
    248   }
    249   appendNFreeG(postsDir, dupG(pubDir));
    250   compactG(postsDir);
    251   sortG(postsDir);
    252 
    253     // generate html code for each post for index.html
    254   cleanAllocateSmallArray(postsIndex);
    255   iterLast(postsDir, L) {
    256     castS(l,L);
    257     cleanCharP(postHtmlFile) = copyRngG(ssGet(l), 11, 0);
    258     replaceG(&postHtmlFile, ".markdown", ".html", 1);
    259     // determine if file is in _posts or _published
    260     if (hasG(pubDir, l)) {
    261       // file is in _published
    262       prependG(l, publishedDir"/");
    263     }
    264     else {
    265       prependG(l, PostsDir"/");
    266     }
    267     setPG(postsDir, iI(postsDir), l);
    268     cleanAllocateSmallJson(postCfg);
    269     cleanAllocateSmallArray(postf);
    270     readFileWithFrontMatter(ssGet(l), postCfg, postf);
    271     // select first publish date for html path: root/category/YY/MM/DD/postName.html
    272     cleanCharP(postDate) = hasG(postCfg, "firstPublishDate") ? copyRngG($(postCfg, "firstPublishDate"), 0, 10) : copyRngG($(postCfg, "date"), 0, 10);
    273     replaceG(&postDate, "-", "/", 0);
    274     cleanSmallArrayP(postUrlA) = createSA($(cfg, "baseurl"),
    275         $(postCfg, "categories"), postDate, postHtmlFile);
    276     cleanCharP(postUrl) = joinSG(postUrlA, "/");
    277     // 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
    278     cleanCharP(date);
    279     if (hasG(postCfg, "firstPublishDate")) {
    280       if (eqG($(postCfg, "date"), $(postCfg, "firstPublishDate"))) goto onlyPublishDate;
    281       // there is an update date
    282       $(postCfg, "date")[10] = 0;
    283       $(postCfg, "firstPublishDate")[10] = 0;
    284       date = catS($(postCfg, "firstPublishDate"), " updated on ", $(postCfg, "date"));
    285     }
    286     else {
    287       onlyPublishDate:
    288       $(postCfg, "date")[10] = 0;
    289       date = dupG($(postCfg, "date"));
    290     }
    291     cleanCharP(s) = formatS("<li>\n"
    292                       "  <span class=\"post-meta\">%s</span>\n"
    293                       "\n"
    294                       "  <h2>\n"
    295                       "    <a class=\"post-link\" href=\"%s\">%s</a>\n"
    296                       "  </h2>\n"
    297                       "</li>\n"
    298                       ,date, postUrl, $(postCfg, "title"));
    299     pushG(postsIndex, s);
    300   }
    301   //cleanCharP(postsIndexS) = joinSG(postsIndex, "\n");
    302   //lv(postsIndexS);
    303 
    304   iter(indexf, L) {
    305     castS(l,L);
    306     if (hasG(l, "{{ posts }}")) {
    307       delElemG(indexf, iterIndexG(indexf));
    308       insertNFreeG(indexf, iterIndexG(indexf), dupG(postsIndex));
    309       break;
    310     }
    311   }
    312 
    313   cleanCharP(layoutFile) = catS(layoutDir,"/",$(indexCfg, "layout"),".html");
    314 
    315   //lv(layoutFile);
    316 
    317   var index = preprocess(layoutFile);
    318 
    319   iter(index, L) {
    320     castS(l,L);
    321     if (hasG(l, content)) {
    322       delElemG(index, iterIndexG(index));
    323       insertNFreeG(index, iterIndexG(index), dupG(indexf));
    324       break;
    325     }
    326   }
    327 
    328   //logG(index);
    329 
    330     // create configuation for index
    331 
    332   /* // TODO list all md files in dir (the pages) */
    333   /* cleanSmallArrayP(pages) = readDirG(rtSmallArrayt, "."); */
    334   /* iter(pages, L) { */
    335   /*   castS(l,L); */
    336   /*   if (not endsWithG(l, ".md")) { */
    337   /*     delElemG(pages, iterIndexG(pages)); */
    338   /*   } */
    339   /* } */
    340   /* lv(pages); */
    341 
    342   cleanAllocateSmallArray(about);
    343   cleanAllocateSmallJson(aboutCfg);
    344   readFileWithFrontMatter("about.md", aboutCfg, about);
    345 
    346     // page list on first line on all pages
    347   char *sitePages = catS("<a class=\"page-link\" href=\"", $(cfg, "baseurl"), $(aboutCfg, "permalink"), "\">", $(aboutCfg, "title"), "</a>");
    348 
    349   cleanAllocateSmallDict(pageValues);
    350 
    351   setNFreeG(pageValues, "_\%title", getNDupO(cfg, "title"));
    352   setNFreeG(pageValues, "_\%description", getNDupO(cfg, "description"));
    353   setNFreeG(pageValues, "_\%site.description", getNDupO(cfg, "description"));
    354   setNFreeG(pageValues, "_\%css", catS($(cfg, "baseurl"), "/css/main.css"));
    355   setNFreeG(pageValues, "_\%url", catS($(cfg, "url"), $(cfg, "baseurl")));
    356   setNFreeG(pageValues, "_\%site.title", getNDupO(cfg, "title"));
    357   setNFreeG(pageValues, "_\%feed", catS($(cfg, "url"), $(cfg, "baseurl"), "/feed.xml"));
    358   setNFreeG(pageValues, "_\%baseurl", getNDupO(cfg, "baseurl"));
    359   setNFreeG(pageValues, "_\%site.pages", sitePages);
    360   setNFreeG(pageValues, "_\%site.email", getNDupO(cfg, "email"));
    361   setNFreeG(pageValues, "_\%site.github_username", getNDupO(cfg, "github_username"));
    362   setNFreeG(pageValues, "_\%site.twitter_username", getNDupO(cfg, "twitter_username"));
    363 
    364   //lv(pageValues);
    365 
    366   simpleTemplatesReplaceKeysWithValues(index, pageValues);
    367 
    368   //logG(index);
    369 
    370   // save index
    371   writeFileG(index, siteDir"/index.html");
    372 
    373 
    374   // generate about page
    375   generateAPageOrAPost("about.md", cfg, null/*postsFeed*/);
    376 
    377   // generate posts
    378   cleanAllocateSmallArray(postsFeed);
    379 
    380   int count = 0;
    381   iterLast(postsDir, L) {
    382     castS(l,L);
    383     // collect html code for 10 last post for feed.xml
    384     generateAPageOrAPost(ssGet(l), cfg, count++ < 10 ? postsFeed : null);
    385   }
    386 
    387   // generate feed.xml
    388   cleanAllocateSmallArray(feed);
    389   cleanAllocateSmallJson(feedCfg);
    390   readFileWithFrontMatter("feed.xml", feedCfg, feed);
    391 
    392   //lv(feedCfg);
    393   //lv(feed);
    394 
    395   iter(feed, L) {
    396     castS(l,L);
    397     if (hasG(l, "{{ posts }}")) {
    398       delElemG(feed, iterIndexG(feed));
    399       insertNFreeG(feed, iterIndexG(feed), dupG(postsFeed));
    400       break;
    401     }
    402   }
    403 
    404   setNFreeG(pageValues, "_\%date", getCurrentDate());
    405   simpleTemplatesReplaceKeysWithValues(feed, pageValues);
    406 
    407   writeFileG(feed, siteDir"/feed.xml");
    408 
    409   // clean tmp files
    410   if (isPath("tmp.html")) rmAll("tmp.html");
    411   if (isPath("tmp.md"))   rmAll("tmp.md");
    412 
    413 
    414   // list all md files in _posts
    415   cleanSmallArrayP(postFiles) = readDirG(rtSmallArrayt, PostsDir);
    416   iter(postFiles, L) {
    417     castS(l,L);
    418     if (not endsWithG(l, ".markdown")) {
    419       delElemG(postFiles, iterIndexG(postFiles));
    420     }
    421   }
    422   //lv(postFiles);
    423 
    424   // move posts from _posts to _published
    425   // add first publish date if needed
    426   iter(postFiles, L) {
    427     castS(p,L);
    428     cleanCharP(postPath) = catS(PostsDir"/", ssGet(p));
    429     cleanAllocateSmallArray(post);
    430     readFileG(post, postPath);
    431 
    432     char *status = "search front matter";
    433     i32 dateIdx  = -1;
    434     iter(post, L) {
    435       castS(l,L);
    436       if (not eqG(status, "search front matter") and startsWithG(l, "firstPublishDate:"))
    437         // found first publish date. Stop.
    438         break;
    439       if (not eqG(status, "search front matter") and eqG(l, "---")) {
    440         if (dateIdx == -1) {
    441           logE("Date not found in front matter, post is %m", p);
    442           break;
    443         }
    444         cleanCharP(pDate) = replaceG($(post, dateIdx), "date:", "firstPublishDate:", 1);
    445         injectG(post, iI(post), pDate);
    446         break;
    447       }
    448       if (eqG(status, "date") and startsWithG(l, "date:")) {
    449         dateIdx = iI(post);
    450         status = "publishDate";
    451       }
    452       if (eqG(status, "search front matter") and eqG(l, "---")) {
    453         status = "date";
    454       }
    455     }
    456 
    457     // move post to _published directory
    458     cleanCharP(pubPath)  = catS(publishedDir"/", ssGet(p));
    459     if (not writeFileG(post, pubPath)) {
    460       logE("Could not write '%s', post is: %m", pubPath, p);
    461       continue;
    462     }
    463     if (not rmAllG(postPath)) {
    464       logE("Could not remove: %s", postPath);
    465       continue;
    466     }
    467   }
    468 }
    469 
    470 void help(void) {
    471   logI(BLD GRN"Forb help\n"RST
    472       "Argument convention: the arguments are words\n\n"
    473       BLD YLW"\nFORB SUBCOMMANDS:\n\n"RST
    474       "  help, -h, --help --- print this help message\n"
    475       "  without argument --- generate the blog in the "siteDir" directory, the posts in "PostsDir" are moved to "publishedDir"\n"
    476       "  new [title]      --- copy default template or when running in a forb directory, create a draft post\n"
    477       "  publish PATH     --- publish a draft, the draft file is moved from the "draftsDir" directory to the "PostsDir" directory and today's date is added to the file name and in the post\n"
    478       "  update PATH      --- move post in "publishedDir" to "PostsDir", edit the post and generate the blog. The update date is added to the post and the first publish date is kept\n"
    479       );
    480 }
    481 
    482 
    483 /**
    484  * publish a post in _drafts to _posts
    485  */
    486 void publish(const char *path) {
    487   // steps
    488   // check extension
    489   // read post
    490   // create date string for filename
    491   // create destination filename
    492   // create date string for front matter
    493   // add date string in front matter
    494   // failed to find front matter or categories
    495   // save posts directory
    496   // remove path
    497 
    498   // check extension
    499   if (not endsWithG(path, ".markdown")) {
    500     logE("'%s' doesn't have the markdown extension. Stop.");
    501     XFailure;
    502   }
    503 
    504   // read post
    505   cleanAllocateSmallArray(post);
    506   readFileG(post, path);
    507 
    508   // create date string for filename
    509   cleanCharP(date) = getCurrentDateYMD();
    510   date[10] = 0;
    511   //lv(date);
    512   //lv(path);
    513 
    514   // create destination filename
    515   cleanCharP(dest) = catS(PostsDir"/", date, "-", basename(path));
    516   //lv(dest);
    517 
    518   // create date string for front matter
    519   date[10] = ' ';
    520   prependG(&date, "date: ");
    521   //lv(date);
    522 
    523   // add date string in front matter
    524   char *status = "search front matter";
    525   iter(post, L) {
    526     castS(l, L);
    527     if (eqG(status, "stop at categories") and startsWithG(l, "categories:")) {
    528       injectG(post, iI(post), date);
    529       goto publishPost;
    530     }
    531     if (eqG(status, "stop at categories") and eqG(l, "---")) {
    532       logE("categories not found in front matter, path is: %s", path);
    533       ret;
    534     }
    535     if (eqG(status, "search front matter") and eqG(l, "---")) {
    536       status = "stop at categories";
    537     }
    538   }
    539 
    540   // failed to find front matter or categories
    541   logE("front matter or categories not found, path is %s", path);
    542   ret;
    543 
    544   publishPost:
    545   // save posts directory
    546   if (not writeFileG(post, dest)) {
    547     logE("Could not write '%s', path is: %s", dest, path);
    548     ret;
    549   }
    550 
    551   // remove path
    552   if (not rmAllG(path)) {
    553     logE("Could not remove: %s", path);
    554   }
    555 
    556   logP("Published draft post to %s", dest);
    557 }
    558 
    559 
    560 /**
    561  * update a post in _published to _posts
    562  */
    563 void update(const char *path) {
    564   // steps
    565   // check extension
    566   // read post
    567   // create destination filename
    568   // create date string for front matter
    569   // change date string in front matter
    570   // failed to find front matter or date
    571   // save posts directory
    572   // remove path
    573 
    574   // check extension
    575   if (not endsWithG(path, ".markdown")) {
    576     logE("'%s' doesn't have the markdown extension. Stop.");
    577     XFailure;
    578   }
    579 
    580   // read post
    581   cleanAllocateSmallArray(post);
    582   readFileG(post, path);
    583 
    584   // create destination filename
    585   cleanCharP(dest) = catS(PostsDir"/", basename(path));
    586   //lv(dest);
    587 
    588   // create date string for front matter
    589   cleanCharP(date) = getCurrentDateYMD();
    590   date[10] = ' ';
    591   prependG(&date, "date: ");
    592   //lv(date);
    593 
    594   // change date string in front matter
    595   char *status = "search front matter";
    596   iter(post, L) {
    597     castS(l, L);
    598     if (eqG(status, "stop at date") and startsWithG(l, "date:")) {
    599       setG(post, iI(post), date);
    600       goto publishPost;
    601     }
    602     if (eqG(status, "stop at date") and eqG(l, "---")) {
    603       logE("date not found in front matter, path is: %s", path);
    604       ret;
    605     }
    606     if (eqG(status, "search front matter") and eqG(l, "---")) {
    607       status = "stop at date";
    608     }
    609   }
    610 
    611   // failed to find front matter or date
    612   logE("front matter or date not found, path is %s", path);
    613   ret;
    614 
    615   publishPost:
    616   // save posts directory
    617   if (not writeFileG(post, dest)) {
    618     logE("Could not write '%s', path is: %s", dest, path);
    619     ret;
    620   }
    621 
    622   // remove path
    623   if (not rmAllG(path)) {
    624     logE("Could not remove: %s", path);
    625   }
    626 
    627   logP("Update already published post in %s", dest);
    628 }
    629 
    630 
    631 /**
    632  * read file filename with front matter and text (any type of text)
    633  *
    634  * \return
    635  *   kv: keys and values for yml code in front matter
    636  *   lines: lines in file filename without front matter
    637  */
    638 bool readFileWithFrontMatter(char *filename, smallJsont *kv, smallArrayt *lines) {
    639   int frontMatterStart = -1, frontMatterEnd = -1;
    640 
    641   // Steps
    642   // find front matter start and end
    643   // parse yml code in front matter
    644   // remove front matter from lines
    645 
    646   if (not isPath(filename)) {
    647     logE("File '%s' not found.", filename);
    648     ret no;
    649   }
    650 
    651   readFileG(lines, filename);
    652 
    653   // find front matter start and end
    654   char *status = "start";
    655   iter(lines, L) {
    656     castS(l,L);
    657     if (eqS(status, "end") and (startsWithG(l, "---"))) {
    658       frontMatterEnd = iterIndexG(lines);
    659       break;
    660     }
    661     if (eqS(status, "start") and (startsWithG(l, "---"))) {
    662       frontMatterStart = iterIndexG(lines)+1;
    663       status = "end";
    664     }
    665   }
    666 
    667   if (frontMatterStart == -1 or frontMatterEnd == -1) {
    668     logE("front matter not found in '%s'", filename);
    669     ret no;
    670   }
    671 
    672   // parse yml code in front matter
    673   var frontMatter = copyRngG(lines, frontMatterStart, frontMatterEnd);
    674 
    675   //lv(frontMatter);
    676 
    677   // TODO change to: parseYMLG(indexCfg, frontMatter);
    678   // when parseYMLG accepts smallArrays
    679   cleanCharP(fm) = joinSG(frontMatter, "\n");
    680   parseYMLG(kv, fm);
    681 
    682   //lv(indexCfg);
    683   finishG(frontMatter);
    684 
    685   // remove front matter from lines
    686   sliceG(lines, frontMatterEnd+1, 0);
    687   //lv(lines);
    688 
    689   ret yes;
    690 }
    691 
    692 /**
    693  * convert post filename ("2020-06-09-getting-started" without markdown)
    694  * to url for linking posts easily in the blog.
    695  *
    696  * The url format is "/category/YYYY/MM/DD/filename.html"
    697  */
    698 char *postToUrl(char *filename) {
    699   cleanCharP(fn) = catS(PostsDir"/",filename,".markdown");
    700   if (not isPath(fn)) {
    701     free(fn);
    702     fn = catS(publishedDir"/",filename,".markdown");
    703   }
    704 
    705   cleanAllocateSmallArray(pf);
    706   cleanAllocateSmallJson(pCfg);
    707   readFileWithFrontMatter(fn, pCfg, pf);
    708 
    709   cleanCharP(postHtmlFile) = copyRngG(fn, 22, 0);
    710   replaceG(&postHtmlFile, ".markdown", ".html", 1);
    711 
    712   // select first publish date for html path: root/category/YY/MM/DD/postName.html
    713   cleanCharP(postDate) = hasG(pCfg, "firstPublishDate") ? copyRngG($(pCfg, "firstPublishDate"), 0, 10) : copyRngG($(pCfg, "date"), 0, 10);
    714   replaceG(&postDate, "-", "/", 0);
    715   char *pUrl = catS("/",$(pCfg,"categories"),"/",postDate,"/",postHtmlFile);
    716 
    717   ret pUrl;
    718 }
    719 
    720 
    721 bool generateAPageOrAPost(char *filename, smallJsont *cfg, smallArrayt *postsFeed) {
    722 
    723   // Steps
    724   // check if filename exists
    725   // parse front matter yml
    726   // convert markdown to html
    727   // add class in <code>
    728   // process {% post_url 2015-12-22-test %}
    729   // add syntax highlighting
    730   // insert html in layout template
    731   // add layout and html code in sub layout
    732   // TODO list pages on first line of all pages
    733   // configure destination path, post url, date and description
    734   // detect if generating a post or a page to select path and parameters
    735   // replace keys with values
    736   // generate feed
    737   // write post html page
    738 
    739   // check if filename exists
    740   if (not isPath(filename)) {
    741     logE("%s not found", filename);
    742     ret false;
    743   }
    744 
    745   // parse front matter yml
    746   cleanAllocateSmallArray(pf);
    747   cleanAllocateSmallJson(pCfg);
    748   readFileWithFrontMatter(filename, pCfg, pf);
    749 
    750   //lv(filename);
    751 
    752   // process {% post_url 2015-12-22-test %}
    753   // skip converting to URL in code blocks
    754   enum {searchcodeblock, incodeblock};
    755   int mdstatus = searchcodeblock;
    756   iter(pf, L) {
    757     castS(l,L);
    758     // process {% post_url 2015-12-22-running-rocket-chat %}
    759     // only outside code block, keep inside code blocks
    760     // (keep this block after code highlighting to get the correct status from the first line
    761     // in the code block)
    762     if (mdstatus equals searchcodeblock and hasG(l, "{\% post_url ")) {
    763       // convert all % post_url to valid url on the line
    764       char *cursor       = hasG(l, "{\% post_url ");
    765       while (cursor) {
    766         char *startp       = cursor;
    767         char *endp         = findS(cursor, " \%}");
    768         int start          = startp - ssGet(l);
    769         int end            = endp   - ssGet(l);
    770         // post filename
    771         char *postFilemane = startp + strlen("{\% post_url ");
    772         *endp              = 0;
    773         //lv(postFilemane);
    774         cleanCharP(url)    = postToUrl(postFilemane);
    775         delG(l, start, end+3);
    776         insertG(l, start, url);
    777         cursor             = findS(ssGet(l)+start, "{\% post_url ");
    778       }
    779       //lv(l);
    780       setPG(pf, iterIndexG(pf), l);
    781     }
    782     if (startsWithG(l, "```"))
    783       mdstatus is mdstatus equals searchcodeblock ? incodeblock : searchcodeblock;
    784   }
    785   //lv(pf);
    786 
    787   // convert markdown to html
    788   writeFileG(pf, "tmp.md");
    789 
    790   // TODO get path once and reuse
    791   cleanCharP(progPath) = shDirname(getRealProgPath());
    792   commandf("%s/shpPackages/md2html/md2html --full-html --ftables --fstrikethrough tmp.md --output=tmp.html", progPath);
    793 
    794   cleanAllocateSmallArray(pHtml);
    795   readFileG(pHtml, "tmp.html");
    796 
    797     // keep only html code between the body tags
    798   int bodyStart = -1, bodyEnd = -1;
    799   iter(pHtml, L) {
    800     castS(l,L);
    801     if (hasG(l, "<body>"))  bodyStart = iterIndexG(pHtml)+1;
    802     if (hasG(l, "</body>")) {
    803       bodyEnd   = iterIndexG(pHtml);
    804       break;
    805     }
    806   }
    807   sliceG(pHtml, bodyStart, bodyEnd);
    808 
    809   // add class in <code>
    810   // add syntax highlighting
    811   enum {searchCode, notspecified, bash, javascript, python, html, coffeescript};
    812   int status = searchCode;
    813   int lastCodeHighlightingLine = -1;
    814   iter(pHtml, L) {
    815     castS(l,L);
    816     // code highlighting
    817     if (status == searchCode and hasG(l, "<code>")) {
    818       if (hasG(l, "<pre><code>")) {
    819         replaceG(l, "<pre><code>", "<figure class=\"highlight\"><pre><code class=\"highlighter-rouge\">", 0);
    820         status = notspecified;
    821       }
    822       else {
    823         replaceG(l, "<code>", "<code class=\"highlighter-rouge\">", 0);
    824       }
    825     }
    826     if (status == searchCode and hasG(l, "<pre><code class=\"language-")) {
    827       lastCodeHighlightingLine = iterIndexG(pHtml);
    828     }
    829     if (status == searchCode and hasG(l, "<pre><code class=\"language-bash\">")) {
    830       status = bash;
    831     }
    832     if (status == searchCode and hasG(l, "<pre><code class=\"language-javascript\">")) {
    833       status = javascript;
    834     }
    835     if (status == searchCode and hasG(l, "<pre><code class=\"language-python\">")) {
    836       status = python;
    837     }
    838     if (status == searchCode and hasG(l, "<pre><code class=\"language-html\">")) {
    839       status = html;
    840     }
    841     if (status == searchCode and hasG(l, "<pre><code class=\"language-coffeescript\">")) {
    842       status = coffeescript;
    843     }
    844     if (hasG(l, "</code></pre>")) {
    845       if (status == searchCode) {
    846         if (lastCodeHighlightingLine == -1) {
    847           logW("line %d in html for "BLD"%s"RST", code highlighting not found. This code block doesn't have highlighting.", iterIndexG(pHtml), &filename[18]);
    848         }
    849         else {
    850           logW("line %d in html for "BLD"%s"RST", code highlighting not recognize: '%s'. This code block doesn't have highlighting.", iterIndexG(pHtml), &filename[18], $(pHtml, lastCodeHighlightingLine));
    851         }
    852       }
    853       else replaceG(l, "</code></pre>", "</code></pre></figure>", 0);
    854       status = searchCode;
    855     }
    856 
    857     // bash highlight
    858     if (status == bash) {
    859       replaceManyG(l, "<pre><code class=\"language-bash\">", "<figure class=\"highlight\"><pre><code class=\"language-bash\" data-lang=\"bash\">",
    860           "cd ", "<span class=\"nb\">cd </span>",
    861           "echo ", "<span class=\"nb\">echo </span>",
    862           "set ", "<span class=\"nb\">set </span>");
    863       // detect comment
    864       if (hasG(l, "#")) {
    865         replaceG(l, "#", "<span class=\"c\">#", 1);
    866         pushG(l, "</span>");
    867       }
    868       // strings
    869       if (not hasG(l, "<figure class=\"highlight\"><pre><code class=\"language-")) {
    870         var c = countG(l, '\'');
    871         if (c != 0 and (c & 1) == 0) {
    872           #define highlightQuotes(q, qS, qE) procbegin\
    873             var len = lenG(l);\
    874             var qL  = lenG(q);\
    875             cleanCharP(str) = malloc(len + ((strlen(qS) + strlen(qE)) * c/2) + 1);\
    876             char *ps  = str;\
    877             char *ln  = ssGet(l);\
    878             enum {qSearch, q1};\
    879             int status = qSearch;\
    880             range(i, len) {\
    881               if (eqIS(ln, q, i)) {\
    882                 if (status == qSearch) {\
    883                   status = q1;\
    884                   strcpy(ps, qS);\
    885                   ps += strlen(qS);\
    886                   *ps++ = ln[i];\
    887                 }\
    888                 elif (status == q1) {\
    889                   status = qSearch;\
    890                   rangeFrom(n, i, i+qL) {\
    891                     *ps++ = ln[n];\
    892                   }\
    893                   i += qL;\
    894                   strcpy(ps, qE);\
    895                   ps += strlen(qE);\
    896                 }\
    897               }\
    898               else {\
    899                 *ps++ = ln[i];\
    900               }\
    901             }\
    902             *ps = 0;\
    903             setValG(l, str);\
    904             procend
    905           highlightQuotes("'", "<span class=\"s1\">", "</span>");
    906         }
    907       }
    908     }
    909 
    910     // javascript highlight
    911     elif (status == javascript) {
    912       replaceManyG(l, "<pre><code class=\"language-javascript\">", "<figure class=\"highlight\"><pre><code class=\"language-javascript\" data-lang=\"javascript\">",
    913                       "*", "<span class=\"o\">*</span>",
    914                       "var", "<span class=\"kd\">var</span>",
    915                       "function", "<span class=\"kd\">function</span>",
    916                       "return", "<span class=\"kd\">return</span>",
    917                       "delete", "<span class=\"kd\">delete</span>",
    918                       "new", "<span class=\"kd\">new</span>",
    919                       "this", "<span class=\"kd\">this</span>",
    920                       "true", "<span class=\"kd\">true</span>",
    921                       "false", "<span class=\"kd\">false</span>",
    922                       "export", "<span class=\"kd\">export</span>",
    923                       "Object", "<span class=\"nb\">Object</span>"
    924                       );
    925       // detect comment
    926       if (hasG(l, "//") and not hasG(l, "://")) {
    927         replaceG(l, "//", "<span class=\"c\">//", 1);
    928         pushG(l, "</span>");
    929       }
    930       // strings
    931       if (not hasG(l, "<figure class=\"highlight\"><pre><code class=\"language-")) {
    932         var c = countG(l, '\'');
    933         if (c != 0 and (c & 1) == 0) {
    934           highlightQuotes("'", "<span class=\"s1\">", "</span>");
    935         }
    936       }
    937     }
    938 
    939     // python highlighting
    940     elif (status == python) {
    941       replaceManyG(l, "<pre><code class=\"language-python\">", "<figure class=\"highlight\"><pre><code class=\"language-python\" data-lang=\"python\">",
    942                       "*", "<span class=\"o\">*</span>",
    943                       "for", "<span class=\"kd\">for</span>",
    944                       "print", "<span class=\"kd\">print</span>",
    945                       "def", "<span class=\"kd\">def</span>",
    946                       "return", "<span class=\"kd\">return</span>",
    947                       "del", "<span class=\"kd\">del</span>",
    948                       "dict", "<span class=\"nb\">dict</span>",
    949                       "range", "<span class=\"nb\">range</span>",
    950                       "zip", "<span class=\"nb\">zip</span>"
    951                       );
    952       // detect comment
    953       if (hasG(l, "#")) {
    954         replaceG(l, "#", "<span class=\"c\">#", 1);
    955         pushG(l, "</span>");
    956       }
    957       // strings
    958       if (not hasG(l, "<figure class=\"highlight\"><pre><code class=\"language-")) {
    959         var c = countG(l, '\'');
    960         if (c != 0 and (c & 1) == 0) {
    961           highlightQuotes("'", "<span class=\"s\">", "</span>");
    962         }
    963       }
    964     }
    965 
    966     // html highlight
    967     elif (status == html) {
    968       // removed "class=", "<span class=\"na\">class=</span>",
    969       // it causes conflicts
    970       replaceManyG(l, "<pre><code class=\"language-html\">", "<figure class=\"highlight\"><pre><code class=\"language-html\" data-lang=\"html\">",
    971                       "&lt;script ", "<span class=\"nt\">&lt;script </span>",
    972                       "&lt;/script&gt;", "<span class=\"nt\">&lt;/script&gt;</span>",
    973                       "&lt;div", "<span class=\"nt\">&lt;div</span>",
    974                       "&lt;/div&gt;", "<span class=\"nt\">&lt;/div&gt;</span>",
    975                       "&lt;a", "<span class=\"nt\">&lt;a</span>",
    976                       "&lt;/a&gt;", "<span class=\"nt\">&lt;/a&gt;</span>",
    977                       "&lt;title&gt;", "<span class=\"nt\">&lt;title&gt;</span>",
    978                       "&lt;/title&gt;", "<span class=\"nt\">&lt;/title&gt;</span>",
    979                       "&lt;link", "<span class=\"nt\">&lt;link</span>",
    980                       "&lt;h1&gt;", "<span class=\"nt\">&lt;h1&gt;</span>",
    981                       "&lt;/h1&gt;", "<span class=\"nt\">&lt;/h1&gt;</span>",
    982                       "&lt;p&gt;", "<span class=\"nt\">&lt;p&gt;</span>",
    983                       "&lt;/p&gt;", "<span class=\"nt\">&lt;/p&gt;</span>",
    984                       "&lt;ul&gt;", "<span class=\"nt\">&lt;ul&gt;</span>",
    985                       "&lt;/ul&gt;", "<span class=\"nt\">&lt;/ul&gt;</span>",
    986                       "&lt;li&gt;", "<span class=\"nt\">&lt;li&gt;</span>",
    987                       "&lt;/li&gt;", "<span class=\"nt\">&lt;/li&gt;</span>",
    988                       "name=", "<span class=\"na\">name=</span>",
    989                       "type=", "<span class=\"na\">type=</span>",
    990                       "input=", "<span class=\"na\">input=</span>",
    991                       "style=", "<span class=\"na\">style=</span>",
    992                       "href=", "<span class=\"na\">href=</span>",
    993                       "src=", "<span class=\"na\">src=</span>"
    994                       );
    995       // strings
    996       if (not hasG(l, "<figure class=\"highlight\"><pre><code class=\"language-")) {
    997         var c = countG(l, "&quot;");
    998         if (c != 0 and (c & 1) == 0) {
    999           highlightQuotes("&quot;", "<span class=\"s\">", "</span>");
   1000         }
   1001       }
   1002     }
   1003 
   1004     // coffeescript highlight
   1005     elif (status == coffeescript) {
   1006       replaceManyG(l, "<pre><code class=\"language-coffeescript\">", "<figure class=\"highlight\"><pre><code class=\"language-coffeescript\" data-lang=\"coffeescript\">",
   1007                       "*", "<span class=\"o\">*</span>",
   1008                       "delete", "<span class=\"kd\">delete</span>",
   1009                       "new", "<span class=\"kd\">new</span>",
   1010                       "this", "<span class=\"kd\">this</span>",
   1011                       "true", "<span class=\"kd\">true</span>",
   1012                       "false", "<span class=\"kd\">false</span>",
   1013                       "export", "<span class=\"kd\">export</span>",
   1014                       "Object", "<span class=\"nb\">Object</span>"
   1015                       );
   1016       // detect comment
   1017       if (hasG(l, "#")) {
   1018         replaceG(l, "#", "<span class=\"c\">#", 1);
   1019         pushG(l, "</span>");
   1020       }
   1021       // strings
   1022       if (not hasG(l, "<figure class=\"highlight\"><pre><code class=\"language-")) {
   1023         var c = countG(l, '\'');
   1024         if (c != 0 and (c & 1) == 0) {
   1025           highlightQuotes("'", "<span class=\"s1\">", "</span>");
   1026         }
   1027       }
   1028     }
   1029 
   1030     setPG(pHtml, iterIndexG(pHtml), l);
   1031   }
   1032 
   1033   //lv(pHtml);
   1034 
   1035   // insert html in layout template
   1036   cleanCharP(layoutFile) = catS(layoutDir,"/",$(pCfg, "layout"),".html");
   1037 
   1038   //lv(layoutFile);
   1039 
   1040   cleanAllocateSmallArray(layoutf);
   1041   cleanAllocateSmallJson(layoutCfg);
   1042   readFileWithFrontMatter(layoutFile, layoutCfg, layoutf);
   1043 
   1044   //lv(layoutf);
   1045 
   1046   // replace content with mardown generated html
   1047 
   1048   iter(layoutf, L) {
   1049     castS(l,L);
   1050     if (hasG(l, content)) {
   1051       delElemG(layoutf, iterIndexG(layoutf));
   1052       insertNFreeG(layoutf, iterIndexG(layoutf), dupG(pHtml));
   1053       break;
   1054     }
   1055   }
   1056 
   1057   // add layout and html code in sub layout
   1058   cleanCharP(subLayoutFile) = catS(layoutDir,"/",$(layoutCfg, "layout"),".html");
   1059 
   1060   var p = preprocess(subLayoutFile);
   1061 
   1062   //logG(p);
   1063 
   1064   iter(p, L) {
   1065     castS(l,L);
   1066     if (hasG(l, content)) {
   1067       delElemG(p, iterIndexG(p));
   1068       insertNFreeG(p, iterIndexG(p), dupG(layoutf));
   1069       break;
   1070     }
   1071   }
   1072 
   1073   // TODO list pages on first line of all pages
   1074   cleanAllocateSmallArray(about);
   1075   cleanAllocateSmallJson(aboutCfg);
   1076   readFileWithFrontMatter("about.md", aboutCfg, about);
   1077 
   1078   char *sitePages    = catS("<a class=\"page-link\" href=\"", $(cfg, "baseurl"), $(aboutCfg, "permalink"), "\">", $(aboutCfg, "title"), "</a>");
   1079 
   1080   // configure destination path, post url, date and description
   1081   cleanCharP(dst)    = null;
   1082   char *pUrl         = null;
   1083   baset *description = null;
   1084   // 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
   1085   cleanCharP(date)   = null;
   1086   // detect if generating a post or a page to select path and parameters
   1087   if (startsWithG(filename, PostsDir"/") or startsWithG(filename, publishedDir"/")) {
   1088     cleanCharP(postHtmlFile) = startsWithG(filename, PostsDir"/") ? copyRngG(filename, 18, 0) : copyRngG(filename, 22, 0);
   1089     replaceG(&postHtmlFile, ".markdown", ".html", 1);
   1090 
   1091     // select first publish date for html path: root/category/YY/MM/DD/postName.html
   1092     cleanCharP(postDate) = hasG(pCfg, "firstPublishDate") ? copyRngG($(pCfg, "firstPublishDate"), 0, 10) : copyRngG($(pCfg, "date"), 0, 10);
   1093     replaceG(&postDate, "-", "/", 0);
   1094     dst  = catS(siteDir,"/",$(pCfg, "categories"),"/",postDate,"/",postHtmlFile);
   1095     pUrl = catS($(cfg, "url"),$(cfg, "baseurl"),"/",
   1096                 $(pCfg,"categories"),"/",postDate,"/",postHtmlFile);
   1097     if (hasG(pCfg, "firstPublishDate")) {
   1098       if (eqG($(pCfg, "date"), $(pCfg, "firstPublishDate"))) goto onlyPublishDate;
   1099       // there is an update date
   1100       $(pCfg, "date")[10] = 0;
   1101       $(pCfg, "firstPublishDate")[10] = 0;
   1102       date = catS($(pCfg, "firstPublishDate"), " updated on ", $(pCfg, "date"));
   1103     }
   1104     else {
   1105       onlyPublishDate:
   1106       $(pCfg, "date")[10] = 0;
   1107       date = dupG($(pCfg, "date"));
   1108     }
   1109 
   1110     iter(pf, L) {
   1111       castS(l,L);
   1112       if (not isBlankG(l)) {
   1113         description = (baset*) dupG(l);
   1114         break;
   1115       }
   1116     }
   1117     replaceManyG((smallStringt*)description, "\"", "", "'", "", "`", "");
   1118     //lv(description);
   1119   }
   1120   else {
   1121     dst         = catS(siteDir, $(pCfg, "permalink"), "index.html");
   1122     pUrl        = catS($(cfg, "url"), $(cfg, "baseurl"), $(pCfg, "permalink"));
   1123     description = getNDupO(cfg, "description");
   1124   }
   1125 
   1126   // replace keys with values
   1127   cleanAllocateSmallDict(pageValues);
   1128 
   1129   setNFreeG(pageValues, "_\%title", getNDupO(pCfg, "title"));
   1130   setNFreeG(pageValues, "_\%description", description);
   1131   setNFreeG(pageValues, "_\%site.description", getNDupO(cfg, "description"));
   1132   setNFreeG(pageValues, "_\%css", catS($(cfg, "baseurl"), "/css/main.css"));
   1133   setNFreeG(pageValues, "_\%url", pUrl);
   1134   setNFreeG(pageValues, "_\%site.title", getNDupO(cfg, "title"));
   1135   setNFreeG(pageValues, "_\%feed", catS($(cfg, "url"), $(cfg, "baseurl"), "/feed.xml"));
   1136   setNFreeG(pageValues, "_\%baseurl", getNDupO(cfg, "baseurl"));
   1137   setNFreeG(pageValues, "_\%site.pages", sitePages);
   1138   setNFreeG(pageValues, "_\%site.email", getNDupO(cfg, "email"));
   1139   setNFreeG(pageValues, "_\%site.github_username", getNDupO(cfg, "github_username"));
   1140   setNFreeG(pageValues, "_\%site.twitter_username", getNDupO(cfg, "twitter_username"));
   1141   if (startsWithG(filename, PostsDir"/") or startsWithG(filename, publishedDir"/")) {
   1142     setG(pageValues, "_\%date", date);
   1143   }
   1144 
   1145   //lv(pageValues);
   1146 
   1147   simpleTemplatesReplaceKeysWithValues(p, pageValues);
   1148 
   1149   // generate feed
   1150   if (postsFeed) {
   1151     cleanCharP(postContent) = joinSG(pHtml, '\n');
   1152     iReplaceManyS(&postContent, "<",  "&lt;",
   1153                                ">",  "&gt;",
   1154                                "\"", "&quot;");
   1155     pushNFreeG(postsFeed, formatS(
   1156       "<item>\n"
   1157       "  <title>%s</title>\n"
   1158       "  <description>%s</description>\n"
   1159       "  <pubDate>%s</pubDate>\n"
   1160       "  <link>%s</link>\n"
   1161       "  <guid isPermaLink=\"true\">%s</guid>\n"
   1162       "  <category>%s</category>\n"
   1163       "</item>\n",
   1164       $(pCfg, "title"), postContent, date, $(pageValues, "_\%url"), $(pageValues, "_\%url"),
   1165       $(pCfg, "categories")));
   1166   }
   1167 
   1168   //logG(p);
   1169 
   1170   // write post html page
   1171   cleanCharP(dstDir) = shDirname(dst);
   1172 
   1173   //lv(dst);
   1174   //lv(dstDir);
   1175 
   1176   if (not isPath(dstDir)) {
   1177     mkdirParents(dstDir);
   1178   }
   1179 
   1180   writeFileG(p, dst);
   1181 
   1182   ret true;
   1183 }
   1184 
   1185 // vim: set expandtab ts=2 sw=2: