Let’s create a project together !

I’m one of those learn by doing guys (mostly), I’m assuming your kind of are too, so let’s build something together.

A bit of context and basic requirements

For this blog, I’m using hugo as a static site generator, and I’m writing my articles in markdown. hugo new does the job, but I want something a bit different, with a folder that contains everything I need for the article (asset, code, etc…). I would like my new post to be prefilled as much as possible so the tool should be able to set any meta. Also, I tend to craft my article squeletton before writting (#trauma #school XD), so I should at least be able to add main headings from the CLI. Because I do not know where this is goping, I won’t just generate a script, I am going to start a new project so I can add more stuff later on.

Leeeeeet’s goooooo !

Generate the project

We are using the project command, more information can be found in the previous article if you need help.

# create a new project called mdd (MarkDown for Didi. Much creativity, such accronym, #wow)
# one subcommand 'article' for now.
parseArger project mdd \
  --description "markdown tools for my blog" \
  --git-repo "DimitriGilbert/mdd" \
  --project-subcommand article

This command will create a new folder mdd with the following structure:

|_ mdd
|_ documentation.md
|_ form.html
|_ Makefile
|_ readme.md
|_ bin
  |_ article
|_ utils
  |_ get_mdd
  |_ install
  |_ webserver 

Our entry point mdd should handle the command routing on its own, no work needed so we go along to article.

Parse the article command

After some careful thoughts, I have refined my requirements which are now the following:

  • create a new folder with the article name
    • if $(pwd) contains a content folder, create the article in it
    • article can have hierarchical categories. (if more than one, the previous is the parent)
      • categories make the path to the article
  • create index.md from template you can specify
  • tags, series, title, draft (on), date (now), summary metas handled by CLI
    • add any other meta you want
  • add headings
    • headings level can be specified

From all that, we know that we’ll need the folowing :

  • arguments :
    • title (the only thing mandatory, so it’s an argument)
  • options :
    • categories (repeatable)
    • folder name (not strictly necessary, but i can forsee a need soooo…)
    • tags (repeatable)
    • series (repeatable)
    • date
    • summary
    • template
    • headings (repeatable)
    • headings level (default 2)
  • flags :
    • draft (on by default)
  • nested options :
    • meta
      • due to bash limitation, no repeatable nested options, so only the last value for a key will be kept

With that in mind, let’s build our parse command to update our bin/article file. Don’t forget to remove comment before executing, they don’t play well with backslashes ^^.

# parse the bin/article file
parseArger parse bin/article \
  # add the title argument, nothing special here
  --pos 'title "article title"' \
  # add the folder name option, using title if none provided. I also declare aliases for this option, namely 'dir' and 'directory'
  --opt 'folder "article folder name" --alias directory --alias dir' \
  # add the categories option, repeatable, with aliases 'cat' and 'parent', a short version of the option is given, namely 'c'
  --opt 'categories "article parent categories" --short c --alias cat --alias parent --repeat' \
  # add the tags option, repeatable, with alias 'tag' and short option 't'
  --opt 'tags "article tags" --repeat --short t --alias tag' \
  # add the series option, with alias 'group' and short option 'g'
  --opt 'series "article belongs to this series" --alias group --short g' \
  # add the date option with short option 'd' and aliases 'publication', 'publish-at'
  --opt 'date "publication date meta" --alias publication --alias publish-at --short d' \
  # add the summary option with short option 's' and aliases 'description', 'desc'
  --opt 'summary "summary meta" --short s --alias description --alias desc' \
  # add the template option with short option 'tpl' and alias 'template'
  --opt 'template "template file to use" --alias tpl' \
  # add the headings option, repeatable, with short option 'h' and aliases 'part', 'h2'
  --opt 'headings "add headings to your file" --repeat --alias part --alias h2' \
  # add the headings level option with short option 'hl' and alias 'headings-level'
  --opt 'headings-level "heading level" --default-value 2 --alias hl' \
  # add the draft flag on by default and with a no-alias as publish 
  #   meaning if you want it off, you can either use --no-draft or --publish
  --flag 'draft "is it a draft" --on --no-alias publish' \
  # add the meta nested option
  --nested-options 'meta "add any meta you want"' \
  # parse in place, -i is the short option
  --in-place

This will give us the following bin/article file :

#!/bin/bash
# @parseArger-begin
# @parseArger-help "mdd article command" --option "help" --short-option "h"
# @parseArger-verbose --option "verbose" --level "0" --quiet-option "quiet"
# @parseArger-declarations
# @parseArger pos title "article title"
# @parseArger opt folder "article folder name" --alias directory --alias dir
# @parseArger opt categories "article parent categories" --short c --repeat --alias cat --alias parent
# @parseArger opt tags "article tags" --short t --repeat --alias tag
# @parseArger opt series "article belongs to this series" --short g --alias group
# @parseArger opt date "publication date meta" --short d --alias publication --alias publish-at
# @parseArger opt summary "summary meta" --short s --alias description --alias desc
# @parseArger opt template "template file to use" --alias tpl
# @parseArger opt headings "add headings to your file" --repeat --alias part --alias h2
# @parseArger opt headings-level "heading level" --default-value "2" --alias hl
# @parseArger flag draft "is it a draft" --on --no-alias publish
# @parseArger nested meta "add any meta you want"
# @parseArger-declarations-end

# @parseArger-utils
_helpHasBeenPrinted=1;
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)";
# @parseArger-utils-end

# @parseArger-parsing
die()
{
	local _ret="${2:-1}"
	test "${_PRINT_HELP:-no}" = yes && print_help >&2
	log "$1" -3 >&2
	exit "${_ret}"
}


begins_with_short_option()
{
	local first_option all_short_options=''
	first_option="${1:0:1}"
	test "$all_short_options" = "${all_short_options/$first_option/}" && return 1 || return 0
}

# POSITIONALS ARGUMENTS
_positionals=();
_optional_positionals=();
_arg_title="";
# OPTIONALS ARGUMENTS
_arg_folder=
_arg_categories=()
_arg_tags=()
_arg_series=
_arg_date=
_arg_summary=
_arg_template=
_arg_headings=()
_arg_headings_level="2"
# FLAGS
_arg_draft="on"
# NESTED
_arg_meta=
declare -A _arg_ns_meta;
_verbose_level="0";



print_help()
{
	_triggerSCHelp=1;

	if [[ "$_helpHasBeenPrinted" == "1" ]]; then
		_helpHasBeenPrinted=0;
		echo -e "mdd article command:"
	echo -e "	title: article title"
	echo -e "	--folder|--directory|--dir <folder>: article folder name"
	echo -e "	-c, --categories|--cat|--parent <categories>: article parent categories, repeatable"
	echo -e "	-t, --tags|--tag <tags>: article tags, repeatable"
	echo -e "	-g, --series|--group <series>: article belongs to this series"
	echo -e "	-d, --date|--publication|--publish-at <date>: publication date meta"
	echo -e "	-s, --summary|--description|--desc <summary>: summary meta"
	echo -e "	--template|--tpl <template>: template file to use"
	echo -e "	--headings|--part|--h2 <headings>: add headings to your file, repeatable"
	echo -e "	--headings-level|--hl <headings-level>: heading level [default: ' 2 ']"
	echo -e "	--draft|--no-draft: is it a draft, on by default (use --no-draft to turn it off)
		no-aliases: --publish,"
	echo -e "	--meta[-<key>] <meta-key-value>: nested, add any meta you want"
	echo -e "Usage :
	$0 <title> [--folder <value>] [--categories <value>] [--tags <value>] [--series <value>] [--date <value>] [--summary <value>] [--template <value>] [--headings <value>] [--headings-level <value>] [--[no-]draft] [--[no-]meta]";
	fi

}

log() {
	local _arg_msg="${1}";
	local _arg_level="${2:-"0"}";
	if [ "${_arg_level}" -le "${_verbose_level}" ]; then
		case "$_arg_level" in
			-3)
				_arg_COLOR="\033[0;31m";
				;;
			-2)
				_arg_COLOR="\033[0;33m";
				;;
			-1)
				_arg_COLOR="\033[1;33m";
				;;
			1)
				_arg_COLOR="\033[0;32m";
				;;
			2)
				_arg_COLOR="\033[1;36m";
				;;
			3)
				_arg_COLOR="\033[0;36m";
				;;
			*)
				_arg_COLOR="\033[0m";
				;;
		esac
		echo -e "${_arg_COLOR}${_arg_msg}\033[0m";
	fi
}

parse_commandline()
{
	_positionals_count=0
	while test $# -gt 0
	do
		_key="$1"
		case "$_key" in
			--dir|--directory|--folder)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_folder="$2"
				shift
				;;
			--folder=*)
				_arg_folder="${_key##--folder=}"
				;;
			--directory=*)
				_arg_folder="${_key##--directory=}"
				;;
			--dir=*)
				_arg_folder="${_key##--dir=}"
				;;
			
			-c|--parent|--cat|--categories)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_categories+=("$2")
				shift
				;;
			--categories=*)
				_arg_categories+=("${_key##--categories=}")
				;;
			--cat=*)
				_arg_categories+=("${_key##--cat=}")
				;;
			--parent=*)
				_arg_categories+=("${_key##--parent=}")
				;;
			-c*)
				_arg_categories+=("${_key##-c}")
				;;
			
			-t|--tag|--tags)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_tags+=("$2")
				shift
				;;
			--tags=*)
				_arg_tags+=("${_key##--tags=}")
				;;
			--tag=*)
				_arg_tags+=("${_key##--tag=}")
				;;
			-t*)
				_arg_tags+=("${_key##-t}")
				;;
			
			-g|--group|--series)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_series="$2"
				shift
				;;
			--series=*)
				_arg_series="${_key##--series=}"
				;;
			--group=*)
				_arg_series="${_key##--group=}"
				;;
			-g*)
				_arg_series="${_key##-g}"
				;;
			
			-d|--publish-at|--publication|--date)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_date="$2"
				shift
				;;
			--date=*)
				_arg_date="${_key##--date=}"
				;;
			--publication=*)
				_arg_date="${_key##--publication=}"
				;;
			--publish-at=*)
				_arg_date="${_key##--publish-at=}"
				;;
			-d*)
				_arg_date="${_key##-d}"
				;;
			
			-s|--desc|--description|--summary)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_summary="$2"
				shift
				;;
			--summary=*)
				_arg_summary="${_key##--summary=}"
				;;
			--description=*)
				_arg_summary="${_key##--description=}"
				;;
			--desc=*)
				_arg_summary="${_key##--desc=}"
				;;
			-s*)
				_arg_summary="${_key##-s}"
				;;
			
			--tpl|--template)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_template="$2"
				shift
				;;
			--template=*)
				_arg_template="${_key##--template=}"
				;;
			--tpl=*)
				_arg_template="${_key##--tpl=}"
				;;
			
			--h2|--part|--headings)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_headings+=("$2")
				shift
				;;
			--headings=*)
				_arg_headings+=("${_key##--headings=}")
				;;
			--part=*)
				_arg_headings+=("${_key##--part=}")
				;;
			--h2=*)
				_arg_headings+=("${_key##--h2=}")
				;;
			
			--hl|--headings-level)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_headings_level="$2"
				shift
				;;
			--headings-level=*)
				_arg_headings_level="${_key##--headings-level=}"
				;;
			--hl=*)
				_arg_headings_level="${_key##--hl=}"
				;;
			
			--draft)
				_arg_draft="on"
				;;
			--no-draft|--publish)
				_arg_draft="off"
				;;
			--meta)
				test $# -lt 2 && die "Missing value for the option: '$_key'" 1
				_arg_meta="$2"
				shift
				;;
			--meta=*)
				_arg_meta="${_key##--meta=}"
				;;
			--meta-*)
				_ns_key="${_key##--meta-}";
				test $# -lt 2 && die "Missing value for the option: '$_key-$_ns_key'" 1;
				_arg_ns_meta[$_ns_key]="$2";
				shift;
				;;
			
			-h|--help)
				print_help;
				exit 0;
				;;
			-h*)
				print_help;
				exit 0;
				;;
			-v|--version)
				print_version;
				exit 0;
				;;
			-v*)
				print_version;
				exit 0;
				;;
			--verbose)
					if [ $# -lt 2 ];then
						_verbose_level="$((_verbose_level + 1))";
					else
						_verbose_level="$2";
						shift;
					fi
					;;
				--quiet)
					if [ $# -lt 2 ];then
						_verbose_level="$((_verbose_level - 1))";
					else
						_verbose_level="-$2";
						shift;
					fi
					;;
				
				*)
				_last_positional="$1"
				_positionals+=("$_last_positional")
				_positionals_count=$((_positionals_count + 1))
				;;
		esac
		shift
	done
}


handle_passed_args_count()
{
	local _required_args_string="title"
	if [ "${_positionals_count}" -gt 1 ] && [ "$_helpHasBeenPrinted" == "1" ];then
		_PRINT_HELP=yes die "FATAL ERROR: There were spurious positional arguments --- we expect at most 1 (namely: $_required_args_string), but got ${_positionals_count} (the last one was: '${_last_positional}').\n\t${_positionals[*]}" 1
	fi
	if [ "${_positionals_count}" -lt 1 ] && [ "$_helpHasBeenPrinted" == "1" ];then
		_PRINT_HELP=yes die "FATAL ERROR: Not enough positional arguments - we require at least 1 (namely: $_required_args_string), but got only ${_positionals_count}.
	${_positionals[*]}" 1;
	fi
}


assign_positional_args()
{
	local _positional_name _shift_for=$1;
	_positional_names="_arg_title ";
	shift "$_shift_for"
	for _positional_name in ${_positional_names};do
		test $# -gt 0 || break;
		eval "if [ \"\$_one_of${_positional_name}\" != \"\" ];then [[ \"\${_one_of${_positional_name}[*]}\" =~ \"\${1}\" ]];fi" || die "${_positional_name} must be one of: $(eval "echo \"\${_one_of${_positional_name}[*]}\"")" 1;
		eval "$_positional_name=\${1}" || die "Error during argument parsing, possibly an ParseArger bug." 1;
		shift;
	done
}

print_debug()
{
	print_help
	# shellcheck disable=SC2145
	echo "DEBUG: $0 $@";
	
	echo -e "	title: ${_arg_title}";
	echo -e "	folder: ${_arg_folder}";
	echo -e "	categories: ${_arg_categories[*]}";
	echo -e "	tags: ${_arg_tags[*]}";
	echo -e "	series: ${_arg_series}";
	echo -e "	date: ${_arg_date}";
	echo -e "	summary: ${_arg_summary}";
	echo -e "	template: ${_arg_template}";
	echo -e "	headings: ${_arg_headings[*]}";
	echo -e "	headings-level: ${_arg_headings_level}";
	echo -e "	draft: ${_arg_draft}";
	echo -e "	meta: ${_arg_meta}";
	for _tmp_k_meta in "${!_arg_ns_meta[@]}"; do
		echo -e "	meta-$_tmp_k_meta: ${_arg_ns_meta[$_tmp_k_meta]}";
	done

}

print_version()
{
	echo "";
}

on_interrupt() {
	die Process aborted! 130;
}

parse_commandline "$@";
handle_passed_args_count;
assign_positional_args 1 "${_positionals[@]}";
trap on_interrupt INT;



# @parseArger-parsing-end
# print_debug "$@"
# @parseArger-end

It’s a whole bunch of code, but mercyfully you do not have to pay attention to it \o/ (I mean, you can if you want, but it’s not necessary).

Instead we are going to focus on the code that is going to use those parsed arguments and stuff !

The (real) code

Well, let’s start fullfilling our requirements !

Folder stuff

I use the log function with a level of 4 for comments ;)

# if nothing else, the title
_container_dir="$_arg_title";

if [ "$_arg_folder" != "" ]; then
	log "folder specified: $_arg_folder" 4;
	_container_dir="$_arg_folder";
fi

# there is categories at least 1
if [ "${#_arg_categories[@]}" -gt 0 ]; then
	log "categories specified:" 4;
	_cat_dir="";
	for _cat in "${_arg_categories[@]}"; do
		_cat_dir+="$_cat/";
	done
	log "	categories path : $_cat_dir" 4;
	_container_dir="$_cat_dir$_container_dir";
fi

# hugo directory, it's a poor check , I don't care ^^
if [ -d "content" ]; then
	log "using content directory" 4;
	_container_dir="content/$_container_dir";
fi

# create the directory if it does not exist
if [ ! -d "$_container_dir" ]; then
	log "creating container directory: $_container_dir" 2;
	mkdir -p "$_container_dir";
fi

Meta stuff

Here we’ll handle all the metas so that we can put that to rest.

# start with the title, it's always here
_metas_str="title: $_arg_title\n";

if [ "$_arg_date" != "" ]; then
	log "date specified: $_arg_date" 4;
	_metas_str+="date: $_arg_date\n";
fi

if [ "$_arg_summary" != "" ]; then
	log "summary specified: $_arg_summary" 4;
	_metas_str+="summary: $_arg_summary\n";
fi

if [ "${#_arg_tags[@]}" -gt 0  ]; then
	log "${#_arg_tags[@]} tags specified:" 4;
	_metas_str+="tags: \n";
	for _tg in "${_arg_tags[@]}"; do
		log "\t$_tg" 4;
		_metas_str+="\t- $_tg\n";
	done
fi

if [ "$_arg_series" != "" ]; then
	log "series specified: $_arg_series" 4;
	_metas_str+="series: $_arg_series\n";
fi

if [ "$_arg_draft" == "on" ]; then
	log "draft on" 4;
	_metas_str+="draft: true\n";
fi

if [ "${#_arg_ns_meta[@]}" -gt 0 ]; then
	log "other metas specified:" 4;
	for _tmp_k_meta in "${!_arg_ns_meta[@]}"; do
		log "\t$_tmp_k_meta: ${_arg_ns_meta[$_tmp_k_meta]}" 4;
		_metas_str+="$_tmp_k_meta: ${_arg_ns_meta[$_tmp_k_meta]}\n";
	done
fi

Creating the headings

My article squeletton straight from the CLI

# empty content by default
_content_str="";
if [ "${#_arg_headings[@]}" -gt 0 ]; then
	_hd_level_str="";
	for (( i=0; i<_arg_headings_level; i++ )); do
		_hd_level_str+="#";
	done

	log "headings specified:" 4;
	for _hd in "${_arg_headings[@]}"; do
		log "\t$_hd" 4;
		_contents_str+="\n$_hd_level_str $_hd\n\n\n";
	done
fi

Creating the index.md file

Creating the file itself, but, damn, even after careful thoughts it looks like I’m missing something….

We’ll have to come back to that later i guess…


# dont erase stuff willy nilly, but, hum, looks like I'm missing something here...
if [ ! -f "${_container_dir}/index.md" ]; then
	if [ "$_arg_template" == "" ] || [ ! -f "$_arg_template" ]; then
		if [ "$_arg_template" != "" ]; then
			log "template $_arg_template not found" -1;
		fi
		log "using default template" 4;
		_arg_template="${_SCRIPT_DIR}/../templates/article.md";
	fi
	sed \
		-e "s/{{metas}}/$_metas_str/g" \
		-e "s/{{content}}/$_contents_str/g" \
		"$_arg_template" > "${_container_dir}/index.md";
else
	die "file already exists: ${_container_dir}";
fi

befor going any further, let’s test what we have so far :

./mdd article "A test article" \
  --cat my-cat \
  --cat "sub category" \
  -s "this is just a test" \
  --folder "My test article" \
  --h2 "A title for my test" --h2 "a second part to the test" --h2 "Conclusion"

Executing this command gives us the following structure :

|_ my-cat
  |_ sub category
    |_ My test article
      |_ index.md

and cat "my-cat/sub category/My test article/index.md" :

---
title: A test article
summary: this is just a test
draft: true

---

## A title for my test



## a second part to the test



## Conclusion

Well, looks like success to me ! Now let’s get back to what we missed earlier !

Allow ovverride of index.md

Yeah, that’s what we forgot, we need to be able to override the index.md file, so let’s add a flag for that.

parseArger parse bin/article -i --flag 'force "erase if exists"'

This modify the parsing code so we just need to update the code accordingly :

this :

if [ ! -f "${_container_dir}/index.md" ]; then
	...

becomes that :

if [ "$_arg_force" == "on" ] && [ -f "${_container_dir}/index.md" ]; then 
  rm "${_container_dir}/index.md" -f; 
fi
if [ ! -f "${_container_dir}/index.md" ] || [ "$_arg_force" == "on" ]; then
	...

I do it this way if (when ?) we add a backup system ;)

Nice to haves

Let"s take care of our QoL as developers and help our future selves, let’s start with documentation.

parseArger document --file ./mdd  --directory ./bin --title "MarkDown for Didi" > documentation.md

I just pipe the result too the file, je suis un geudin, faut pas me chercher !

Completion is next, and as usual for me, something crashes, the “normal” command is as follow :

parseArger completely "mdd" ./mdd --subcommand-directory ./bin

which fails for me, but I use this workaround :

parseArger completely "mdd" ./mdd --subcommand-directory ./bin --no-run-completely > completely.yaml
completely preview > completely.bash

Why does this work when otherwise fail ? \_(ツ)_/

That done, the rc file require creation, this is the content :

if [ "${MDD_DIR}" != "" ]; then
  alias mdd="${MDD_DIR}/mdd";
  [ -f "${MDD_DIR}/completely.bash" ] && source "${MDD_DIR}/completely.bash";
fi

A small detour by the readme file to fill a bit of information :

# Markdown for Didi

Markdown tools for my blog.
This is a tutorial project for [parseArger](https://github.com/DimitriGilbert/parseArger).

Tidy up !

I’ll remove the form and the makefile, I don’t need them for this project. And if I end up doing, their just a command away ^^

rm -f form.html Makefile

If you followed along you might have extra directory from your test, don’t forget to clean them ! And we’re just left with a commit to be done for now !

git add .
git commit -m "first commit"
# hub create <repo>
git push origin main

What now ?

I won’t re write a heroic fantasy introduction in lieu of conclusion this time, but for real, I kinda have nothing more to teach you on parseArger for now.

As for mdd, I’ll probably add some stuff to it, but I don’t know what yet, so I’ll just leave it there for now. bug report, feature request and PR are welcome though, I don’t promise quick support but I’ll sure have a look ;)

I’ll probably go over a few other stuff I created before writting again about parseArger, but it’ll be back, I promise !

As always, you can report issues or suggest ideas on parseArger github repository. Or even better, you can submit a pull request if you added something you find meaningful.

Thanks for the read and I hope you found it useful (or at least entertaining :D )

See you around and happy coding !