Odkąd przemigrowałem z Wordpressa na Jekylla, to na mojej liście rzeczy do zrobienia był punkt dotyczący stworzenia skryptu, który pozwoliłby mi na tworzenie zalążków tekstów za pomocą jednej komendy. Co prawda jest jekyll-compose, ale nie spełnia on moich wymagań. Jest zbyt ubogi w stosunku do tego co wygenerowane został w czasie migracji. Wniosek jest jeden. Trzeba stworzyć własne narzędzie. Tak najprościej byłoby wziąć wspomniany wyżej plugin i dopisać potrzebne rzeczy, ale…

Po pierwsze średnio znam rubiego. Po drugie to co chcę osiągnąć jest bliżej moich prywatnych wymagań, niż wymagań ogółu. Padło zatem na starego dobrego basha.

Szablon

Na początku stworzyłem szablon pliku, który będzie wzorcem do generowania:

Listing 1. Szablon

---
id: IDEN
title: 'TITLE'
date: 'DATET08:37:00+02:00'
author: Koziołek
layout: post
guid: 'https://koziolekweb.pl/?p=IDEN'
permalink: PERMALINK
categories:
  - Programowanie
tags:
  - Programowanie

---

Flagi IDEN, TITLE, DATE i PERMALINK chcę zastąpić argumentami czy to wyliczonymi, czy pobranymi z linii poleceń. Całość zapisać w pliku, którego nazwa ma format DATE-TYTUL-KEBAB_CASEM.md. Przy czym tytuł w nazwie pliku oraz w PERMALINK musi zostać pozbawiony polskich znaków, znaków interpunkcyjnych i nawiasów. To oznacza trochę rzeźby, ale damy radę :)

Funkcje pomocnicze

Na początek potrzebujemy kilku funkcji pomocniczych, które zrobią nam robotę ze zmianą znaków w tytule oraz zamienią format daty z yyyy-mm-dd na yyyy/mm/dd. Zacznijmy od tej ostatniej.

Format daty

Tutaj sprawa jest bardzo prosta.

Listing 2. Zamiana formatu daty

replace_dash_with_slash() {
    local input="$1"
    echo "$input" | tr '-' '/'
}

Bierzemy dowolny tekst i zamieniamy wszystkie znaki - na znaki /.

Znaki interpunkcyjne i nawiasy

Tu sprawa robi się odrobinę bardziej zabawna, ale nadal da się żyć:

Listing 3. Usunięcie znaków interpunkcyjnych i nawiasów

remove_punctuation_and_brackets() {
    local input="$1"
    echo "$input" | tr -d '[:punct:](){}[]<>'
}

Generalnie polecenie tr uznaje jedynie niewielki podzbiór wyrażeń regularnych. Stąd taki dziwny zapis. Idziemy dalej.

Polskie znaki

I nie tylko polskie. Wszelkie litery spoza zakresu ASCII chcemy zamienić na ich odpowiedniki w ASCII.

Listing 4. Zamiana polskich znaków

to_ascii() {
    local input="$1"
    echo "$input" | iconv -f UTF-8 -t ASCII//TRANSLIT
}

Polecenie iconv zamieni nam ciąg w UTF-8 na ciąg w ASCII z użyciem transliteracji (TRANSLIT). TransliteracjaW, to proces zamiany znaków jednego alfabetu na inny. Najbardziej znaną transliteracją jest zamiana ; na ; (grecki znak zapytania) w kodzie javascript i patrzenie na frontasie dostają palpitacji :D

Kebab case

Kolejny krok to zamiana tytułu na zapisany za pomocą kebab-case – wszystkie litery to małe litery, spacje zastępujemy -

Listing 5. Doner time

to_kebab_case() {
    local input="$1"
    echo "$input" | tr '[:upper:]' '[:lower:]' | sed -e 's/ /-/g' -e 's/^-//' -e 's/-$//'
}

Tutaj samo tr nam nie wystarczy. O ile zamiana wielkich liter na małe jest prosta to już podmiana spacji może przysporzyć pewnych problemów. Jeżeli przekażemy do funkcji ciąg znaków zaczynający się lub kończący spacją, to ta ostatnia spacja też zostanie zamieniona na -. Za pomocą sed nie tylko zmieniamy spacje na - ('s/ /-/g'), ale też usuwamy początkowy ('s/^-//') i końcowy ('s/-$//') znak - jeżeli trzeba.

Identyfikator

W sumie nie jest on potrzebny, ale w czasie migracji pozostał, więc i jego przeniesiemy.

Listing 6. Wyliczamy ID

max_id () {
  local m_id=0
  m_id=$(find ../_posts/ -maxdepth 1 -type f -name "*.md" -exec head -n 2 {} + |  awk '/id: [0-9]/ {print $2}' | sort -n | tail -n 1)
  m_id=$((m_id + 1))
  echo $m_id
}

Po pierwsze musimy popracować w kontekście opublikowanych artykułów (../_post). Tutaj mam jedno założenie – skrypt będzie siedział w katalogu _drafts. Następnie:

  • szukamy wszystkich plików .md w opublikowanych plikach – find ../_posts/ -maxdepth 1 -type f -name "*.md"
  • bierzemy pierwsze dwie linijki – -exec head -n 2 {} +
  • odsiewamy linię z identyfikatorem – awk '/id: [0-9]/
  • wypisujemy sam identyfikator – '{print $2}'
  • sortujemy numerycznie – sort -n
  • wybieramy ostatni wynik – tail -n 1
  • dodajemy do niego 1 m_id=$((m_id + 1))

Z ciekawości wrzuciłem ten kod do AI i poprosiłem o optymalizację i średnio to wyszło. Dodał tylko sprawdzenia czy aby na pewno nie nadpisaliśmy m_id pustą wartością.

Budujemy właściwy program

Czas przejść do właściwego programu. Będę potrzebował jeszcze jednej funkcji, która zbuduje mi tytuł w formacie przyjaznym przeglądarkom:

Listing 7. Tworzymy tytuł

to_title() {
    local input="$1"
    local nopunc=$(remove_punctuation_and_brackets "$input")
    local kebab=$(to_kebab_case "$nopunc")
    to_ascii "$kebab"
}

Tu właśnie używamy większości funkcji pomocniczych, które stworzyliśmy wcześniej. Kolejny krok to wczytanie szablonu i podmiana odpowiednich elementów:

Listing 8. Generujemy zalążek z szablonu

create_draft() {
    local input_file="template.md"
    local url_title="$(to_title "$t_param")"
    local output_file="$d_optional_param-$url_title.md"
    local iden=$(max_id)
    local title="$t_param"
    local url_date="$(replace_dash_with_slash $d_optional_param)"
    local permalink="/$(replace_dash_with_slash $d_optional_param)/$(to_title "$t_param")"

    if [[ ! -f "$input_file" ]]; then
        echo "Input file does not exist."
        return 1
    fi

    if [[ -z "$output_file" ]]; then
        echo "Output file name is not set."
        return 1
    fi

    local content=$(<"$input_file")

    content="${content//IDEN/$iden}"
    content="${content//TITLE/$title}"
    content="${content//DATE/$d_optional_param}"
    content="${content//PERMALINK/$permalink}"
    echo "$content" > "$output_file"
    echo "Flags replaced and saved to $output_file"
}

Teraz jeszcze musimy pobrać argumenty i je obrobić. Będą dwa. Pierwszy to tytuł, a drugi, opcjonalny, to data dzienna publikacji. Domyślna data publikacji to dzisiaj.

Listing 9. Kompletny program

#!/usr/bin/env bash

set -eo pipefail

# Initialize variables
d_optional_param=$(date +%Y-%m-%d)
t_param=""

max_id () {
  local m_id=0
  m_id=$(find ../_posts/ -maxdepth 1 -type f -name "*.md" -exec head -n 2 {} + |  awk '/id: [0-9]/ {print $2}' | sort -n | tail -n 1)
  m_id=$((m_id + 1))
  echo $m_id
}

to_kebab_case() {
    local input="$1"
    echo "$input" | tr '[:upper:]' '[:lower:]' | sed -e 's/ /-/g' -e 's/^-//' -e 's/-$//'
}

to_ascii() {
    local input="$1"
    echo "$input" | iconv -f UTF-8 -t ASCII//TRANSLIT
}

remove_punctuation_and_brackets() {
    local input="$1"
    echo "$input" | tr -d '[:punct:](){}[]<>'
}

replace_dash_with_slash() {
    local input="$1"
    echo "$input" | tr '-' '/'
}

to_title() {
    local input="$1"
    local nopunc=$(remove_punctuation_and_brackets "$input")
    local kebab=$(to_kebab_case "$nopunc")
    to_ascii "$kebab"
}

create_draft() {
    local input_file="template.md"
    local url_title=$(to_title "$t_param")
    local output_file="$d_optional_param-$url_title.md"
    local iden=$(max_id)
    local title="$t_param"
    local url_date=$(replace_dash_with_slash $d_optional_param)

    local permalink="/$url_date/$url_title"

    if [[ ! -f "$input_file" ]]; then
        echo "Input file does not exist."
        return 1
    fi

    if [[ -z "$output_file" ]]; then
        echo "Output file name is not set."
        return 1
    fi

    local content=$(<"$input_file")

    content="${content//IDEN/$iden}"
    content="${content//TITLE/$title}"
    content="${content//DATE/$d_optional_param}"
    content="${content//PERMALINK/$permalink}"
    echo "$content" > "$output_file"
    echo "Flags replaced and saved to $output_file"
}

usage() {
    echo "Usage: $0 -d [optional] -t <multiple words>"
    exit 1
}

while [[ "$#" -gt 0 ]]; do
    case "$1" in
        -d)
            shift
            d_optional_param="${1:-$(date +%Y-%m-%d)}"
            shift
            ;;
        -t)
            shift
            if [[ -n "$1" ]]; then
                t_param="$1"
                shift
                while [[ "x-$1" != "x-" && -n "$1" && "$1" != "-"* ]]; do
                    t_param="$t_param $1"
                    shift
                done
            else
                usage
            fi
            ;;
        *)
            echo "Unknown parameter passed: $1"
            usage
            ;;
    esac
done

if [[ -z "$t_param" ]]; then
    usage
fi

echo "$(create_draft)"

Pominąłem opcję set -u, która przerywa działanie skryptu, gdy zmienna nie jest zainicjowana. Jej ustawienie „psuje” przetwarzanie parametru tytułu jeżeli ten zawiera nawiasy. Głupie, ale działa. I to jest najważniejsze.