~ritho/rweb

321a6808742cbfb0f91afb5d7d4b4f6628f1ffe9 — Ritho 3 months ago a50f98c
Implement the blog functionality.

Signed-off-by: Ritho <palvarez@ritho.net>
17 files changed, 819 insertions(+), 391 deletions(-)

M .golangci.yaml
D assets/articles/moving-back-to-spain.org
D assets/articles/new-job.org
D assets/articles/octopress-3.org
D assets/articles/perl-distribution.org
D assets/articles/sip-basics.org
D assets/articles/sip-protocol.org
D assets/articles/test.org
D assets/articles/uhl-keyboard.org
A assets/posts/.gitignore
M assets/views/articles.html
M internal/engine/engine.go
M internal/engine/pages/blog/blog.go
A internal/engine/pages/blog/post/post.go
A internal/engine/pages/blog/section/section.go
A internal/engine/storage/blog/article/article.go
A internal/engine/storage/blog/blog.go
M .golangci.yaml => .golangci.yaml +0 -2
@@ 150,8 150,6 @@ linters:
    - typecheck
    - staticcheck
    - unused
    - varcheck
    - deadcode
    - revive
    - stylecheck
    - gosec

D assets/articles/moving-back-to-spain.org => assets/articles/moving-back-to-spain.org +0 -31
@@ 1,31 0,0 @@
#+TITLE: Moving back to Spain
#+DESCRIPTION: I've decided to move back to Spain
#+DATE: 2019-05-09
#+AUTHOR: Ritho
#+EMAIL: palvarez@ritho.net
#+LAYOUT: post
#+CATEGORIES: General
#+LANGUAGE: en
#+TAGS: Personal, Work
#+PUBLISHED: true

Hi all,

After more than three and a half years in Mexico working at Segundamano Mexico,
I've decided to accept an offer from [[https://www.zaleos.net/][Zaleos]] and come back to Spain. As you may
know, I've been working at Schibsted (the company that owns Segundamano) for
eight years, first in Madrid for four years and a half approximately and later
in Mexico City for the last three and a half years,. This time has been an
amazing time, both personally (I've met some great people, some of them will
remain friends in the future) and professionally (I can't even list all the
things I've learned during this time). But at a certain point, I felt that I
needed a new challenge to keep growing professionally. At the same time, I wanted
to be closer to my family, since we were more than 10000 km away, and that is
where Zaleos comes.

Zaleos is a software development company that develops IP based solutions to
provide to the Public Safety sector with the much-needed ability to interface
with multiple sources of data and receive critical, real-time information from
callers in need. I expect to see technologies related to VoIP and real-time
protocols, a full new field for me in the working environment, so don't be
surprised if I start posting entries about real-time protocols :).

D assets/articles/new-job.org => assets/articles/new-job.org +0 -42
@@ 1,42 0,0 @@
#+TITLE: New job at Lana
#+DESCRIPTION: I've changed recently of job, from Zaleos to Lana
#+DATE: 2020-03-24
#+AUTHOR: Ritho
#+EMAIL: palvarez@ritho.net
#+LAYOUT: post
#+CATEGORIES: General
#+LANGUAGE: en
#+TAGS: Personal, Work
#+PUBLISHED: true

This year has come with an important change in my working side. In mid January
I've finished my work relationship with Zaleos, and, since the beginning of
March, I've started working at Lana.

As you know, in May I decided to come back to Spain, and I had to thank the
opportunity to Zaleos. I have learned a lot in Zaleos during the time I've worked
there, and I think they have a great team. Still, at some point, we had some
differences in our daily job and, finally, they decided to let me go. I think it
wasn't an easy decision for them. For me, besides the initial bad sensation (it
never feels good when any company let you go), I think it was a good decision
because I wasn't feeling too comfortable either. I think that probably with better
communication and some work we could make it work. Still, finally, it was their
decision, and I understand it.

On the bride side, I've started working at Lana since the beginning of March as
Senior Backend Developer. Lana is a Fintech that started inside Cabify with a
solution to disperse payments to the Cabify drivers, and it has some exciting
projects to grow the company. The backend stack is done entirely in Golang, using
technologies like Kubernetes or Elastic Search, and, since it's a new startup,
there's a lot of exciting work to do. The project has a bit more than two years,
so you can imagine the amount of work to do and new ideas we want to try. I hope
not only to learn a lot about technology and the financial business side but also
aport my knowledge and experience to the project. Since the tech team is split
into several locations (we have developers in Mexico, Chile, Amsterdam, Madrid,
Malaga, ...) I'll keep working from Cordoba, which is good news for me too :).

I have several pending posts about things I've learned in Zaleos about SIP, and
I'm sure I'll publish new blog posts related to my current work, so keep in the
loop.

Happy Hacking!

D assets/articles/octopress-3.org => assets/articles/octopress-3.org +0 -37
@@ 1,37 0,0 @@
#+TITLE: Octopress 3
#+DESCRIPTION: Blogging in Octopress 3
#+DATE: 2016-06-10 vie 00:05
#+AUTHOR: Pablo Álvarez de Sotomayor Posadillo
#+EMAIL: palvarez@ritho.net
#+LAYOUT: post
#+CATEGORIES: Emacs
#+LANGUAGE: en
#+TAGS: Emacs, octopress, org-mode
#+PUBLISHED: true

I have to admit that I've been quite lazy in the last three years in terms of
blogging, but recently I had some time, so I've moved my old blog to octopress 3.

In general, it looks like it's easier to work with once is set up correctly, but
there's not so much documentation as with octopress 2 and build it up is a little
bit more try and error.

The essential directory structure is very similar to any other Jekyll based blog
(with directories for include, templates, posts, ...), but, opposite to Octopress
2, you don't have a Rakefile to build the blog neither a source dir with the sass
theme and the blog structure. So, if you want to see your changes locally you have
to run Jekyll serve.

One of the things that I think they have enhanced in Octopress 3 is the publishing
management, because, instead of using Rake as with Octopress 2, they provide a ruby
command to manage all that, providing octopress new {post|page|draft} to generate
a new post, page or draft and octopress {publish|unpublish} to publish a draft or
unpublish a post, so the management of posts is now easier from my point of view.
Finally, with octopress deploy, you can deploy your changes to the final destination
(Github, another git repo, rsync, S3, ...).

You can have more information about Octopress 3 and Jekyll in the
[[https://github.com/octopress/octopress][Octopress 3 GitHub page]] and in the [[https://jekyllrb.com/][Jekyll project page]]. So, now that I have my blog
again working and linked with org-octopress I hope I can write more often.

Happy Hacking!

D assets/articles/perl-distribution.org => assets/articles/perl-distribution.org +0 -85
@@ 1,85 0,0 @@
#+TITLE: How to build your own perl module distribution
#+DESCRIPTION: This is a little guide on how to build a perl module distribution.
#+DATE: 2019-03-10
#+AUTHOR: Ritho
#+EMAIL: palvarez@ritho.net
#+LAYOUT: post
#+CATEGORIES: Perl
#+LANGUAGE: en
#+TAGS: Perl, Programming
#+PUBLISHED: true

Every time I have the chance of programming in Perl, I enjoy it more and more
because it's a language that adapts to my needs very easily. Sometimes I want to
create a module to reuse the functionality that repeats over and over in my Perl
scripts. Still, until not long ago, I have never known how to prepare my Perl
modules to build and install them easily in my computers.

Recently I have finished reading the O'Reilly book [[https://www.intermediateperl.com/about/][Intermediate Perl]], which
contains a specific chapter on how to build your modules to distribute them and
another one on how to publish your package in CPAN. I want to summarize here what
I have learned in order both to have my own notes and don't forget about it and
to share my experience with you.

The first thing to do when you want to build your own Perl module distribution is
to initialize the first module you want to develop. For that, you can use
*/module-starter/*, which is a simple starter kit for any module. To install it
*/CPAN/* is your friend:

#+begin_src sh
cpan -i Module::Starter
cpan -i Module::Starter::AddModule
cpan -i Module::Build
#+end_src

Once installed you can use the command */module-starter/* to create the basic
structure of the module:

#+begin_src sh
module-starter --mb --module="ModuleName"
#+end_src

This command creates a new subdirectory called */ModuleName/* with the basic
structure of the module, including the directory */t/* for tests, the directory
*/lib/* with the module file */ModuleName.pm/*, the Perl script */Build.PL/* with
configurations to generate the module distribution (something like a Makefile)
and the script */Build/* to generate the module distribution. To build the module
itself, you usually use the following commands are:

#+begin_src sh
Perl Build.PL
./Build
./Build test
./Build disttest
./Build dist
./Build install
#+end_src

The first command, */perl Build.PL/*, is going to update the */Build/* script
when any configuration changes (if you add a new module or change the version
number, for example). After the */Build/* script is updated, you can use it to
build the module, test it, run the tests for the distribution package, generate
a distribution package or install it in your system.

The module generated with */module-starter/* has the default structure for any
Perl module, including the */perldoc/* documentation section, the code that the
module includes and the end of the module itself, so it's ready for you to add
the code and put the proper documentation.

If you want to add a new module to the distribution, you can add it with the next
command:

#+begin_src sh
module-starter --module="ModuleName2" --dist=.
#+end_src

As you see, the command is pretty similar to the first one used to generate the
distribution, but with a new argument, */--dist=./*, which indicates that the
module is going to be added to the distribution located in the current directory.
If you want to see more documentation on the building process, you can check the
[[https://metacpan.org/pod/Module::Build][module::Build documentation]], including the configuration options for */Build.PL/*.

Once you are happy with your distribution, you can publish it to CPAN, which is
the subject for a next blog entry.

Happy Hacking!

D assets/articles/sip-basics.org => assets/articles/sip-basics.org +0 -85
@@ 1,85 0,0 @@
#+TITLE: What is SIP?
#+DESCRIPTION: In this post we'll see what is SIP and which is his role in VoIP.
#+DATE: 2020-03-25
#+AUTHOR: Ritho
#+EMAIL: palvarez@ritho.net
#+LAYOUT: post
#+CATEGORIES: SIP
#+LANGUAGE: en
#+TAGS: SIP, VoIP, Work
#+PUBLISHED: true

During the time I've worked at Zaleos, I've learned a lot about VoIP in general
and the SIP protocol in particular, so I want to share this knowledge in several
blog posts. This first post explains what VoIP is and what's the role of SIP in
it.

Voice over Internet (VoIP), also called IP telephony, is a group of technologies
for the delivery of voice communications and multimedia sessions over Internet
Protocol (IP) networks. VoIP includes several services (voice, fax, SMS,
voice-messaging, ...) that communicates over the public Internet rather than via
the public switched telephone network (PSTN).

The principles of VoIP telephone calls are similar to traditional telephony and
involves signalling, channel setup, digitization of the analogue voice signals
and encoding. Instead of transmitting the call over a circuit-switched network,
the information is packetized in IP datagrams and transmitted over a packet-switched
network.

In the beginning, early providers of VoIP services offered business models and
technical solutions that mirrored the architecture of the PSTN. After that,
second-generation providers built closed networks for private user bases, offering
the benefit of free calls and convenience while potentially charging for access to
other communication networks, causing a limitation of the freedom of users to
mix-and-match third-party hardware and software.

This was the motivation for the third-generation of providers, such as Google Talk,
that adopted the concept of federated VoIP, separating themselves from the
architecture of the legacy networks. These solutions allow dynamic interconnections
between users on any two domains on the Internet when a user wants to make a call.

Apart from VoIP phones, you can also use personal computers and other Internet
access devices like your smartphone to make calls and send SMS text messages,
independently of the type of Internet connection (Wi-Fi, 4G, ethernet, ...). Because
of that, we have seen how the communication system have been consolidating, using
just one application for everything.

Both for convenience and adaptability, the technologies involved in VoIP are usually
separated into session handling and multimedia streaming technologies. That made
arise many standards both for multimedia streaming and signalling that can be
combined in several ways depending on the requirements of the system built.

For the transport of the multimedia streams, VoIP can use several media delivery
protocols that encode audio and video by the use of specific codecs, optimizing the
media stream based on the application requirements and network bandwidth. While some
implementations rely on narrowband and compressed speech, others support high-fidelity
stereo codecs.

Since the multimedia streaming and their coding standards are a whole world by
themselves, we're not going to go deeper in this post describing the different
existing codecs for audio and video and how they work, we leave it for a future
post.

Although in VoIP you can use TCP and UDP to transport the data, most of the times
it's used UDP, which doesn't guarantee the packet delivery nor the order of the
packet arrival. To guarantee a good quality of transmission, the standards
organizations have defined several protocols on top of UDP. The most used one for
media streaming is the Real Time Protocol (RTP), which implements part of the
capabilities that have TCP but oriented to media streaming.

There are also several protocols available and standardized among the years to
handle the session, but SIP is the most widely used protocol because of its
simplicity and extensibility. With the Session Initiation Protocol we can establish
the session, route the messages to the participants, negotiate the media transmission
(codecs, protocols, ...) and finish the session.

This separation between the session managing and the media streaming makes easy
changing the media capabilities, like adding or removing video to the streaming,
changing the media codecs to use or the video quality or adding new participants
to the call.

Once we have seen what's SIP and what is his role in the VoIP world, in the next
post we'll see the main SIP messages needed to initialize a session and what is
the primary format for a SIP message.

Happy Hacking!

D assets/articles/sip-protocol.org => assets/articles/sip-protocol.org +0 -20
@@ 1,20 0,0 @@
#+TITLE: The SIP protocol
#+DESCRIPTION: In this post we'll see the basics of the SIP protocol
#+DATE: 2020-04-26
#+AUTHOR: Ritho
#+EMAIL: palvarez@ritho.net
#+LAYOUT: post
#+CATEGORIES: SIP
#+LANGUAGE: en
#+TAGS: SIP
#+PUBLISHED: true

In the previous post we described what's VoIP and what's the role that SIP plays
inside its ecosystem. Now, as I promised, it's time we see how SIP works, at least
in its basic form, since there are several RFCs that complements it with several
extensions.

As we saw in the previous post, SIP stands for Session Initiation Protocol, so
its main role is to initiate a multimedia session between two or more peers. Its
main specification is defined in the RFC ...


D assets/articles/test.org => assets/articles/test.org +0 -0
D assets/articles/uhl-keyboard.org => assets/articles/uhl-keyboard.org +0 -48
@@ 1,48 0,0 @@
#+TITLE: UHK Keyboard review
#+DESCRIPTION: After a while with a new UHK keyboard, I want to share my impressions.
#+DATE: 2018-09-15
#+AUTHOR: Ritho
#+EMAIL: palvarez@ritho.net
#+LAYOUT: post
#+CATEGORIES: UHK
#+LANGUAGE: en
#+TAGS: UHK, Keyboard
#+PUBLISHED: true

Short after I came into Mexico to work, in December 2015, I found a project in
Crowd Supply for a new keyboard that draws my attention, the Ultimate Hacking
Keyboard (a.k.a [[https://ultimatehackingkeyboard.com/][UHK]]), and I decided to buy one. The characteristics that impressed
me the most were that it was a mechanical keyboard (I'm a fan of mechanical
keyboards) and, more important, it could split into two halves.

After a long wait (its delivery was delayed for more than two years) I can say
I'm delighted with my acquisition. It's not the perfect keyboard, but it's quite
close. What I miss the most from the keyboard is some extra separated keys, like
the escape key or the arrow keys, but, on the other hand, it's easily configurable
via its agent, so I'll be doing some changes to be more and more comfortable with
it. The agent is Free Software, provided by the company that makes the keyboard,
and available for Linux, macOS and Windows, which is something to appreciate.

As I suspected, the fact that it is a mechanical keyboard makes it very gentle
with my fingers, and the fact that I can split it and orient it with several
sideboards makes me feel more natural in my posture when I write with the UHK.
It also comes with a wood palm rest that is comfortable for my wrist, and the
size is compact but not too small, so I can move it around from my apartment to
my office in my bag and, at the same time, is not too small that I type several
keys at the same time.

I know it's not a cheap keyboard. Still, the quality worth it, and the agent, the
keyboard firmware and its schematics are free (as in Free Software), so you are
not only buying a great keyboard but supporting the Free Software and Hardware
movement, apart from an honest business. I also appreciate a lot the excellent
communication the UHK makers do, since they share all the progress of building
the keyboard and the modules, the difficulties they have found, ... I can assure
it's not common to find a project that opens in all their path from the original
idea of the final product.

The only thing I'm waiting now for is two extensions I ordered with the keyboard.
The first one is a trackball to attach the mouse, and the second one is three extra
keys that I plan to use to define some macros to make more automatic some recurrent
tasks.

Happy Hacking!

A assets/posts/.gitignore => assets/posts/.gitignore +2 -0
@@ 0,0 1,2 @@
*
!.gitignore

M assets/views/articles.html => assets/views/articles.html +4 -4
@@ 13,17 13,17 @@
  {{end}}
  <p class="pagination">Pages:
	{{if ne .Prev .Current}}
	<a href="/blog/page/{{.Prev}}" >prev</a>
	<a href="/blog/{{.Prev}}" >prev</a>
	{{end}}
	{{range .Pages}}
	{{if .Active}}
	<span>{{.ID}}</span>
	<span>{{.Tag}}</span>
	{{else}}
	<a href="/blog/page/{{.ID}}" >{{.ID}}</a>
	<a href="/blog/{{.ID}}" >{{.Tag}}</a>
	{{end}}
	{{end}}
	{{if ne .Next .Current}}
	<a href="/blog/page/{{.Next}}" >next</a>
	<a href="/blog/{{.Next}}" >next</a>
	{{end}}
  </p>
</section>

M internal/engine/engine.go => internal/engine/engine.go +6 -3
@@ 29,6 29,7 @@ import (
	"git.sr.ht/~ritho/rweb/internal/engine/pages/blog"
	"git.sr.ht/~ritho/rweb/internal/engine/pages/projects"
	"git.sr.ht/~ritho/rweb/internal/engine/pages/static"
	stg "git.sr.ht/~ritho/rweb/internal/engine/storage/blog"
)

// Engine for the web server to serve the content related to a specific path.


@@ 37,13 38,15 @@ type Engine interface {
}

type engine struct {
	cfg *config.Config
	cfg         *config.Config
	blogStorage stg.Storage
}

// New returns a new engine.
func New(cfg *config.Config) Engine {
	return &engine{
		cfg: cfg,
		cfg:         cfg,
		blogStorage: stg.New(cfg.Assets),
	}
}



@@ 68,7 71,7 @@ func (e *engine) Get(path string, params map[string][]string) (string, error) {
		page = static.New(&e.cfg.Site, sections, &section, e.cfg.Assets,
			e.templateFromPath(path))
	case "blog":
		page = blog.New(&e.cfg.Site, sections, &section, e.cfg.Assets,
		page = blog.New(&e.cfg.Site, e.blogStorage, sections, &section, e.cfg.Assets,
			e.templateFromPath(path))
	case "projects":
		page = projects.New(&e.cfg.Site, sections, &section, e.cfg.Assets,

M internal/engine/pages/blog/blog.go => internal/engine/pages/blog/blog.go +129 -34
@@ 20,17 20,26 @@ package blog

import (
	"bytes"
	"fmt"
	"html/template"
	"log"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"

	"git.sr.ht/~ritho/rweb/internal/config"
	"git.sr.ht/~ritho/rweb/internal/engine/content"
	"git.sr.ht/~ritho/rweb/internal/engine/pages"
	"git.sr.ht/~ritho/rweb/internal/engine/pages/blog/post"
	"git.sr.ht/~ritho/rweb/internal/engine/pages/blog/section"
	"git.sr.ht/~ritho/rweb/internal/engine/storage/blog"
)

// Static represents a static web page.
type Static struct {
// Blog engine
type Blog struct {
	cfg        *config.Site
	storage    blog.Storage
	sections   []content.Section
	section    *config.Section
	assetsPath string


@@ 42,11 51,12 @@ type Static struct {
}

// New returns a new static page.
func New(cfg *config.Site, sections []content.Section, section *config.Section,
	assetsPath, content string,
func New(cfg *config.Site, storage blog.Storage, sections []content.Section,
	section *config.Section, assetsPath, content string,
) pages.Page {
	return &Static{
	return &Blog{
		cfg:        cfg,
		storage:    storage,
		section:    section,
		sections:   sections,
		assetsPath: assetsPath,


@@ 62,67 72,152 @@ func New(cfg *config.Site, sections []content.Section, section *config.Section,
}

// WithContent sets the page template content to use.
func (s *Static) WithContent(content string) pages.Page {
	s.content = content
	return s
func (b *Blog) WithContent(content string) pages.Page {
	b.content = content
	return b
}

// WithParam add a new parameter to the web page.
func (s *Static) WithParam(name string, value interface{}) pages.Page {
	if s.params == nil {
		s.params = make(map[string]interface{}, 1)
func (b *Blog) WithParam(name string, value interface{}) pages.Page {
	if b.params == nil {
		b.params = make(map[string]interface{}, 1)
	}

	s.params[name] = value
	b.params[name] = value

	return s
	return b
}

func (s *Static) body() (string, error) {
	return "", nil
func (b *Blog) fetchContent() string {
	content := strings.TrimPrefix(b.content, b.section.Path[1:])
	if content == "" {
		return ""
	}

	if content == b.notFound {
		return b.notFound
	}

	return content[1:]
}

func (b *Blog) isPost(content string) bool {
	if strings.HasPrefix(content, "category/") {
		return false
	}

	r := regexp.MustCompile(`[a-zA-Z-]+`)

	return r.MatchString(content)
}

func (b *Blog) body() (string, error) {
	content := b.fetchContent()
	log.Printf("Content: %s", content)
	if content == b.notFound {
		return "Page not found", nil
	}

	if b.isPost(content) {
		log.Printf("Content %s is a post", content)
		a, err := b.storage.FetchByTitle(content)
		if err != nil {
			return "", err
		}

		p := &post.Post{
			Article:    a,
			AssetsPath: b.assetsPath,
		}

		return p.Content(), nil
	}

	var err error
	page := 0
	r := regexp.MustCompile(`[\d]+$`)
	if r.MatchString(content) {
		s := r.FindString(content)
		content = strings.TrimSuffix(content, s)
		content = strings.TrimSuffix(content, "/")
		page, err = strconv.Atoi(s)
		if err != nil {
			page = 0
		}
	}

	var category string
	link := fmt.Sprintf("/blog/%d", page)
	if strings.HasPrefix(content, "category/") {
		category = strings.TrimPrefix(content, "category/")
		link = fmt.Sprintf("/blog/category/%s/%d", category, page)
	}

	log.Printf("Loading the category '%s' and the page %d", category, page)
	articles, err := b.storage.FetchByCategory(category)
	tmpl := &section.Section{
		Name: category,
		Link: link,
	}

	if err != nil {
		return "", err
	}

	categories, err := b.storage.Categories()
	if err != nil {
		return "", err
	}

	log.Printf("Categories: %+v\nArticles: %+v", categories, articles)
	tmpl = tmpl.WithAssets(b.assetsPath).WithArticles(articles).
		WithCategories(categories).WithCurrentPage(page)

	return tmpl.Content(), nil
}

// Render the web page.
func (s *Static) Render() (string, error) {
	body, err := s.body()
func (b *Blog) Render() (string, error) {
	body, err := b.body()
	if err != nil {
		return s.WithContent(s.notFound).Render()
		log.Printf("Error: %s", err)
		return b.WithContent(b.notFound).Render()
	}

	if s.section.Title == "" {
		s.section.Title = s.cfg.Title
	if b.section.Title == "" {
		b.section.Title = b.cfg.Title
	}

	if s.section.Description == "" {
		s.section.Description = s.cfg.Description
	if b.section.Description == "" {
		b.section.Description = b.cfg.Description
	}

	if s.params == nil {
		s.params = make(map[string]interface{}, 1)
	if b.params == nil {
		b.params = make(map[string]interface{}, 1)
	}

	s.params["page"] = content.Content{
	b.params["page"] = content.Content{
		Head: content.Head{
			Title:       s.cfg.Title,
			Description: s.cfg.Description,
			CSS:         s.section.CSS,
			Title:       b.cfg.Title,
			Description: b.cfg.Description,
			CSS:         b.section.CSS,
		},
		Header: content.Header{
			Title:    s.section.Title,
			Sections: s.sections,
			Title:    b.section.Title,
			Sections: b.sections,
		},
		Body: content.Body{
			Content: body,
		},
		Footer: content.Footer{
			Copy:  s.cfg.Copy,
			Email: s.cfg.Email,
			Copy:  b.cfg.Copy,
			Email: b.cfg.Email,
		},
		JS: s.section.Javascripts,
		JS: b.section.Javascripts,
	}

	var content bytes.Buffer
	err = s.tmpl.ExecuteTemplate(&content, s.name, s.params)
	err = b.tmpl.ExecuteTemplate(&content, b.name, b.params)

	return content.String(), err
}

A internal/engine/pages/blog/post/post.go => internal/engine/pages/blog/post/post.go +55 -0
@@ 0,0 1,55 @@
// Package post implements the post model.
package post

/* Copyright (C) 2022 Pablo Alvarez de Sotomayor Posadillo

   This file is part of web.

   web is free software: you can redistribute it and/or modify
   it under the terms of the GNU Affero General Public License as
   published by the Free Software Foundation, either version 3 of
   the License, or (at your option) any later version.

   web is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with web. If not, see <http://www.gnu.org/licenses/>. */

import (
	"bytes"

	"github.com/golang/glog"

	"git.sr.ht/~ritho/rweb/internal/engine/storage/blog/article"
	"git.sr.ht/~ritho/rweb/internal/server/renderer"
)

// Post represents a blog post.
type Post struct {
	Article    *article.Article
	AssetsPath string
}

// Content returns the html content.
func (a *Post) Content() string {
	var articleBuffer bytes.Buffer
	var content bytes.Buffer

	r := renderer.New(a.AssetsPath)
	err := r.Render(&articleBuffer, "article", a.Article, nil)
	if err != nil {
		glog.Errorf("Error rendenring the article: %s", err)
	}

	err = r.Render(&content, "body", map[string]interface{}{
		"body": articleBuffer.String(),
	}, nil)
	if err != nil {
		glog.Errorf("Error rendenring the article: %s", err)
	}

	return content.String()
}

A internal/engine/pages/blog/section/section.go => internal/engine/pages/blog/section/section.go +215 -0
@@ 0,0 1,215 @@
// Package section implements the blog sections for the html template.
package section

/* Copyright (C) 2022 Pablo Alvarez de Sotomayor Posadillo

   This file is part of web.

   web is free software: you can redistribute it and/or modify
   it under the terms of the GNU Affero General Public License as
   published by the Free Software Foundation, either version 3 of
   the License, or (at your option) any later version.

   web is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with web. If not, see <http://www.gnu.org/licenses/>. */

import (
	"bytes"
	"log"
	"strconv"

	"git.sr.ht/~ritho/rweb/internal/engine/storage/blog/article"
	"git.sr.ht/~ritho/rweb/internal/server/renderer"
)

const defaultArticlesPerPage = 5

type page struct {
	ID       int
	Tag      string
	articles []article.Article
	Active   bool
}

type information struct {
	assetsPath   string
	articles     []article.Article
	categories   []string
	pages        []page
	previousPage int
	currentPage  int
	nextPage     int
}

// Section implements the sections of the blog.
type Section struct {
	Name string
	Link string
	info *information
}

// WithAssets adds the assets path to the section.
func (s *Section) WithAssets(assets string) *Section {
	s2 := new(Section)
	*s2 = *s
	if s2.info == nil {
		s2.info = new(information)
	}

	s2.info.assetsPath = assets

	return s2
}

// WithArticles adds a list of articles to the section.
func (s *Section) WithArticles(articles []article.Article) *Section {
	s2 := new(Section)
	*s2 = *s
	if s2.info == nil {
		s2.info = new(information)
	}

	s2.info.articles = articles

	return s2
}

// WithCategories adds a list of categories to the section.
func (s *Section) WithCategories(categories []string) *Section {
	s2 := new(Section)
	*s2 = *s
	if s2.info == nil {
		s2.info = new(information)
	}

	s2.info.categories = categories

	return s2
}

// WithCurrentPage adds the current page of the section.
func (s *Section) WithCurrentPage(page int) *Section {
	s2 := new(Section)
	*s2 = *s
	if s2.info == nil {
		s2.info = new(information)
	}

	if s2.info.pages == nil {
		s2 = s2.WithArticlesPerPage(defaultArticlesPerPage)
	}

	maxPage := len(s2.info.pages) - 1
	if maxPage < 0 {
		maxPage = 0
	}

	if page < 0 {
		page = 0
	}

	if page > maxPage {
		page = maxPage
	}

	s2.info.currentPage = page
	s2.info.previousPage = page - 1
	if s2.info.previousPage < 0 {
		s2.info.previousPage = 0
	}

	s2.info.nextPage = page + 1
	if s2.info.nextPage > maxPage {
		s2.info.nextPage = maxPage
	}

	for i := range s2.info.pages {
		s2.info.pages[i].Active = (i == s2.info.currentPage)
	}

	return s2
}

// WithArticlesPerPage adds the number of articles per page to the section.
func (s *Section) WithArticlesPerPage(articlesPerPage int) *Section {
	s2 := new(Section)
	*s2 = *s
	if s2.info == nil {
		s2.info = new(information)
	}

	articles := len(s.info.articles)
	numPages := articles / articlesPerPage
	rest := articles % articlesPerPage
	if rest > 0 {
		numPages++
	}

	s2.info.currentPage = 0
	s2.info.pages = make([]page, numPages)
	for i := range s2.info.pages {
		lastArticle := (i + 1) * articlesPerPage
		if lastArticle > articles {
			lastArticle = articles
		}

		s2.info.pages[i].articles = s2.info.articles[i*articlesPerPage : lastArticle]
		s2.info.pages[i].ID = i
		s2.info.pages[i].Tag = strconv.Itoa(i + 1)
		s2.info.pages[i].Active = (i == s2.info.currentPage)
	}

	s2.info.previousPage = s2.info.currentPage - 1
	if s2.info.previousPage < 0 {
		s2.info.previousPage = 0
	}

	s2.info.nextPage = s2.info.currentPage + 1
	if s2.info.nextPage > numPages-1 {
		s2.info.nextPage = numPages - 1
	}

	return s2
}

// Content returns the html content.
func (s *Section) Content() string {
	var articlesBuffer bytes.Buffer
	var content bytes.Buffer

	if s.info.pages == nil {
		s = s.WithArticlesPerPage(defaultArticlesPerPage)
	}

	articles := s.info.articles
	if len(s.info.pages) > 0 {
		articles = s.info.pages[s.info.currentPage].articles
	}

	r := renderer.New(s.info.assetsPath)
	err := r.Render(&articlesBuffer, "articles", map[string]interface{}{
		"Articles":   articles,
		"Categories": s.info.categories,
		"Pages":      s.info.pages,
		"Prev":       s.info.previousPage,
		"Current":    s.info.currentPage,
		"Next":       s.info.nextPage,
	}, nil)
	if err != nil {
		log.Printf("Error rendenring the list of articles: %s", err)
	}

	err = r.Render(&content, "body", map[string]interface{}{
		"body": articlesBuffer.String(),
	}, nil)
	if err != nil {
		log.Printf("Error rendenring the list of articles: %s", err)
	}

	return content.String()
}

A internal/engine/storage/blog/article/article.go => internal/engine/storage/blog/article/article.go +183 -0
@@ 0,0 1,183 @@
// Package article implements a blog article entry.
package article

/* Copyright (C) 2022 Pablo Alvarez de Sotomayor Posadillo

   This file is part of web.

   web is free software: you can redistribute it and/or modify
   it under the terms of the GNU Affero General Public License as
   published by the Free Software Foundation, either version 3 of
   the License, or (at your option) any later version.

   web is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with web. If not, see <http://www.gnu.org/licenses/>. */

import (
	"io"
	"os"
	"regexp"
	"strings"
)

const (
	startCode      = `<code class="${1}">`
	endCode        = "</code>"
	startParagraph = "<p>"
	endParagraph   = "</p>"
	startBold      = "<strong>"
	endBold        = "</strong>"
	breakTag       = "<br/>"
	linkTag        = `<a href="${1}">${2}</a>`
	metaLine       = "#+"
	newLine        = "\n"
)

// Article generates a blog article entry based on its data.
type Article struct {
	Metadata       map[string]string
	BlogCategories []string
	Categories     []string
	Tags           []string
	Content        []string
	path           string
	Published      bool
	isCode         bool

	url       *regexp.Regexp
	startCode *regexp.Regexp
	endCode   *regexp.Regexp
}

// New returns a new article instance.
func New(path string) (*Article, error) {
	a := Article{
		path:       path,
		Metadata:   make(map[string]string),
		Categories: make([]string, 0),
		Tags:       make([]string, 0),
		Content:    make([]string, 0),

		url:       regexp.MustCompilePOSIX("\\[\\[(.*)\\]\\[(.*)\\]\\]"),
		startCode: regexp.MustCompilePOSIX("#\\+begin_src (.*)"),
		endCode:   regexp.MustCompilePOSIX("#\\+end_src"),
	}

	return &a, a.parse()
}

func (a *Article) parse() error {
	data, err := os.ReadFile(a.path)
	if err != nil {
		return err
	}

	content := string(data)
	if content == "" {
		return io.EOF
	}

	for _, line := range strings.Split(content, newLine) {
		a.parseLine(line)
	}

	if len(a.Content) > 0 {
		a.Content[len(a.Content)-1] += endParagraph
	}

	return nil
}

func (a *Article) parseLine(line string) {
	parsed := false
	if strings.Contains(line, metaLine) {
		parsed = a.parseMetadata(line)
	}

	if !parsed {
		a.parseLineContent(line)
	}
}

func metadataKey(title string) (string, bool) {
	metadataConversion := map[string]string{
		"#+TITLE":       "title",
		"#+DESCRIPTION": "description",
		"#+DATE":        "date",
		"#+AUTHOR":      "author",
		"#+EMAIL":       "email",
		"#+LANGUAGE":    "language",
	}
	key, ok := metadataConversion[title]

	return key, ok
}

func (a *Article) parseMetadata(line string) bool {
	data := strings.Split(line, ":")
	if key, ok := metadataKey(data[0]); ok {
		a.Metadata[key] = data[1]
		return true
	}

	switch data[0] {
	case "#+CATEGORIES":
		a.Categories = strings.Split(data[1], ",")
		return true
	case "#+TAGS":
		a.Tags = strings.Split(data[1], ",")
		return true
	case "#+PUBLISHED":
		a.Published = (data[1] == "true")
		return true
	case "#+LAYOUT":
		return true
	}

	return false
}

func (a *Article) parseLineContent(line string) {
	if a.isCode {
		a.Content = append(a.Content, "")
	} else if strings.TrimSpace(line) == "" || len(a.Content) == 0 {
		if len(a.Content) > 0 {
			a.Content[len(a.Content)-1] += endParagraph
		}
		a.Content = append(a.Content, startParagraph)
	}

	pos := len(a.Content) - 1
	line = a.startCode.ReplaceAllStringFunc(line, func(match string) string {
		a.isCode = true
		a.Content[pos-1] = strings.ReplaceAll(a.Content[pos-1], endParagraph, "")
		a.Content[pos] = strings.ReplaceAll(a.Content[pos], startParagraph, "")
		return a.startCode.ReplaceAllString(match, startCode)
	})
	line = a.endCode.ReplaceAllStringFunc(line, func(match string) string {
		a.isCode = false
		return endCode
	})
	line = a.url.ReplaceAllString(line, linkTag)
	line = strings.ReplaceAll(line, "*/", startBold)
	line = strings.ReplaceAll(line, "/*", endBold)

	a.Content[pos] += " " + line
	if a.isCode {
		a.Content[pos] += breakTag
	}
}

// TitleToURL converts the article title to an url path.
func (a *Article) TitleToURL() string {
	resp := strings.TrimSpace(a.Metadata["title"])
	resp = strings.ReplaceAll(resp, " ", "-")
	resp = strings.ToLower(resp)

	return strings.ReplaceAll(resp, "?", "")
}

A internal/engine/storage/blog/blog.go => internal/engine/storage/blog/blog.go +225 -0
@@ 0,0 1,225 @@
// Package blog implements the blog storage.
package blog

/* Copyright (C) 2022 Pablo Alvarez de Sotomayor Posadillo

   This file is part of web.

   web is free software: you can redistribute it and/or modify
   it under the terms of the GNU Affero General Public License as
   published by the Free Software Foundation, either version 3 of
   the License, or (at your option) any later version.

   web is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with web. If not, see <http://www.gnu.org/licenses/>. */

import (
	"errors"
	"io"
	"log"
	"os"
	"path"
	"sort"
	"strings"

	"git.sr.ht/~ritho/rweb/internal/engine/storage/blog/article"
)

const (
	postsPath     = "posts"
	postExtension = ".org"
)

// Storage implements the blog storage.
type Storage interface {
	FetchAll() ([]article.Article, error)
	FetchByCategory(category string) ([]article.Article, error)
	FetchByTitle(title string) (*article.Article, error)
	Categories() ([]string, error)
}

type storage struct {
	assetsPath string
	articles   map[string]article.Article
	categories map[string]bool
}

// New returns a new storage object.
func New(assetsPath string) Storage {
	return &storage{
		assetsPath: assetsPath,
		articles:   make(map[string]article.Article),
		categories: make(map[string]bool),
	}
}

// reload all the articles into the storage object.
func (s *storage) reload() error {
	files, err := os.ReadDir(path.Join(s.assetsPath, postsPath))
	if err != nil {
		return err
	}

	for _, file := range files {
		if err := s.loadArticle(file); err != nil {
			return err
		}
	}

	s.removeDeletedArticles()
	s.updateCategories()

	return nil
}

func (s *storage) loadArticle(file os.DirEntry) error {
	if !s.articleNeedsFetch(file.Name()) {
		return nil
	}

	a, err := article.New(path.Join(s.assetsPath, postsPath, file.Name()))
	if errors.Is(err, io.EOF) {
		log.Printf("Article %s empty", file.Name())
		return nil
	}

	if err != nil {
		return err
	}

	for i := range a.Categories {
		a.Categories[i] = strings.Trim(a.Categories[i], " ")
	}

	s.articles[file.Name()] = *a
	s.saveCategories(a)

	return nil
}

func (s *storage) articleNeedsFetch(filename string) bool {
	if _, ok := s.articles[filename]; ok {
		log.Printf("Post %s already added", filename)
		return false
	}

	if !strings.HasSuffix(filename, postExtension) {
		log.Printf("Post %s not an org file", filename)
		return false
	}

	return true
}

func (s *storage) saveCategories(a *article.Article) {
	for _, cat := range a.Categories {
		if _, ok := s.categories[cat]; !ok {
			s.categories[cat] = true
		}
	}
}

func (s *storage) removeDeletedArticles() {
	for filename := range s.articles {
		_, err := os.Stat(path.Join(s.assetsPath, postsPath, filename))
		if os.IsNotExist(err) {
			log.Printf("Removing article %s", filename)
			delete(s.articles, filename)
		}
	}
}

// FetchAll return the list of all articles.
func (s *storage) FetchAll() ([]article.Article, error) {
	if err := s.reload(); err != nil {
		return nil, err
	}

	res := make([]article.Article, 0, len(s.articles))
	for i := range s.articles {
		res = append(res, s.articles[i])
	}

	sort.Slice(res, func(i, j int) bool {
		return res[i].Metadata["date"] > res[j].Metadata["date"]
	})

	return res, nil
}

// FetchByCategory fetches a list of articles by category.
func (s *storage) FetchByCategory(category string) ([]article.Article, error) {
	if category == "" {
		return s.FetchAll()
	}

	if err := s.reload(); err != nil {
		return nil, err
	}

	res := make([]article.Article, 0)
	for i := range s.articles {
		for _, cat := range s.articles[i].Categories {
			if strings.EqualFold(category, cat) {
				res = append(res, s.articles[i])
			}
		}
	}

	sort.Slice(res, func(i, j int) bool {
		return res[i].Metadata["date"] > res[j].Metadata["date"]
	})

	return res, nil
}

// FetchByTitle fetches an article by its title.
func (s *storage) FetchByTitle(title string) (*article.Article, error) {
	if err := s.reload(); err != nil {
		return nil, err
	}

	for i := range s.articles {
		article := s.articles[i]
		if article.TitleToURL() == title {
			return &article, nil
		}
	}

	return nil, errors.New("article not found")
}

// Categories returns the list of blog categories.
func (s *storage) Categories() ([]string, error) {
	if err := s.reload(); err != nil {
		return nil, err
	}

	return s.sortedCategories(), nil
}

func (s *storage) sortedCategories() []string {
	res := make([]string, 0, len(s.categories))
	for cat := range s.categories {
		res = append(res, cat)
	}
	sort.Slice(res, func(i, j int) bool {
		return res[i] > res[j]
	})

	return res
}

func (s *storage) updateCategories() {
	cats := s.sortedCategories()
	for i := range s.articles {
		article := s.articles[i]
		article.BlogCategories = cats
		s.articles[i] = article
	}
}