Un lloc personal amb Wagtail ...

Captura de pantalla de 2022-03-25 07-27-38

Després de gairebé una dècada, decideixo actualitzar el meu lloc web personal utilitzant mitjans propis i programari opensource. Com sempre, una bona oportunitat per repassar conceptes.

Un poco de historia

Sobre 1995 aquest era l'aspecte que tenia la meva web, només html molt bàsic, algun gif animat i un parell de cgis. Simple i lleuger; tant les xarxes com els sistemes de lèpoca ho exigien.

joslumar archieological web page

El NCSA Mosaic de l'època tampoc no podia fer gaire més quan es tractava de renderitzar taules imbricades, i això amb el suport de taules activat; sí, tenia una opció al menú per això. Recordem que al 1.996 es va publicar una extensió a l'estandard HTML2, definit a la RFC 1942. Amb el netscape (navigator) la cosa va començar a progressar, i nous elements de html permetien maquetar una mica millor sense separar realment les dades de la maquetació, a més del suport dels gifs animats, que li donaven una mica de vida a les pàgines sempre que no et passessis, és clar.

No puc dir on estava allotjat el meu primer lloc, amb el seu ftp de recursos per darrere, però en aquella època hi havia una certa llibertat impossible dimaginar ara. Aleshores un accedia a la xarxa com podia, des de la universitat, des del treball, o mitjançant connexions dial-up, que et donaven 58kbps com a molt. Descarregar-se tota una distribució de linux slackware a tropecients disquets de 1.44M era tota una aventura. L'aparició de les primeres tarifes planes et permetien estar un gran nombre d'hores connectat, això sí que no podies utilitzar el telèfon perquè per a la connexió es feia servir per la pròpia xarxa telefònica commutada.

joslumar 20 century web

Aquest és l'aspecte que tenia la meva pàgina a finals del segle passat. Amb l'arribada de les connexions ADSL, la majoria d'operadors solien tenir l'opció d'IP fixa, cosa que facilitava molt l'autohostatge incús del DNS, mitjançant l'ús de petits ordinadors de poc consum com els basats en Intel Atom.

Des de llavors van ser molts els motius, en què no entraré, pels quals aquest contingut va anar quedant estàtic i obsolet, gairebé com a testimoni arqueològic i punt final d'una època a la qual mai tornarem: connexions dialup, el uucp, usenet, les news, les bbs, els fancins de Legión i la islatortuga de Àngel ... "viva el jamón y el vino".

lynx-oldweb

L'avantatge d'usar només html, permetia una bona visualització fins i tot en navegadors basats només en terminal, com ellynx, avui dia pràcticament impossible, tot i tenir una mica de cura, amb l'ús i l'abús de javascript, css, DOM, ...

La renovació

L'objectiu del projecte és tenir un lloc web que permeti ser un punt de compartició de projectes i idees, galeria bàsica d'astrofotografia i portal d'accés a recursos en línia. Un dels requisits era que permetés la localització (opcional) de les pàgines creades en diverses llengües i que fos mitjanament usable en tota mena de dispositius; bravo pel @screen, flex i grid del css.

Després de donar-li moltes voltes em vaig decidir per wagtail:

  • Basat en django (python), i integrable en un entorn amb altres aplicacions django ja existents.
  • Molt orientat a desenvolupadors, però un cop configurat no requereix tocar res per afegir contingut.
  • Interfície d'administració personalitzable
  • Moltes contribucions, extensions, actiu, i raonablement actualitzat
  • Opcions d'internacionalització i multillenguatge.
  • Lleuger

Per donar-li la funcionalitat que desitjava li afegiu el mòdul puput, que automàticament us dóna la funcionalitat de bloc i, amb extensions sense tocar les fonts, la galeria d'astrofotografia.

Punts clau

No entraré en aquest post a descriure pas per pas com està realitzat aquest lloc, però sí alguns aspectes clau o punts importants que m'he trobat durant el disseny i construcció. Hi ha multitud de tutorials per la xarxa de força qualitat que expiquen des de com fer els primers passos amb python i django, com dissenyar i construir un entorn de producció wagtail:

StreamFields

Es tracta d'una forma elegant de generar estructures de presentació complexes, només amb un inconvenient principal: les migracions. Quan canvies el teu model de dades et veus obligat a construir una migració personalitzada per poder transformar lestructura de dades antiga a la nova, per exemple afegir un nou camp. El procediment de migració estàndard de wagtail no suporta la conversió d'aquestes estructures, i cal fer-ho a mà generant una migració manual:

    manage.py makemigrations --empty yourappname

i editar a conveniència el fitxer que genera al directori migrations/ de l'aplicació afectada, per després llançar la migració.

La bona notícia és que les dades del streamfield estan representades per una estructura json estàndard. Aquestes estructures són fàcils de manejar amb python per mitjà de la representació del json mitjançant llistes i diccionaris.

A continuació un exemple simple de migració per passar d'un tipus de bloc a un altre, que necessita un nou camp, fent ús de la recursivitat per buscar i tractar l'estructura del json.

   import json
   from importlib import import_module
   from django.db import migrations, models
   from django.core.serializers.json import DjangoJSONEncoder

   def migrate_block(obj):
      """Recursively fetch values from nested JSON."""
      def extract(obj):
         global match
         """Recursively search for values of key in JSON tree."""
         if isinstance(obj, dict):
            if 'type' in obj.keys() and obj['type'] == 'image':
             obj['type'] = 'image2'
             obj['value'] = {'image_type':'center','aImage': obj['value']}
            else:
               for (k, v) in obj.items():
                  if isinstance(v, (dict, list)):
                     extract(v)
         elif isinstance(obj, list):
             for item in obj:
                extract(item)
      extract(obj)

   def forwards(apps, schema_editor):
      AboutPage = import_module('home.models').AboutPage
      for page in AboutPage.objects.all():
         page_data = json.loads(page.to_json())
         content_data = json.loads(page_data['content'])
         migrate_block(content_data)
         page.content=json.dumps(content_data,cls=DjangoJSONEncoder)
         page.save()
         for revision in page.revisions.all():
            content_data = json.loads(revision.content_json)
            revision_data = content_data
            migrate_block(content_data)
            # StreamField data is stored in revision.content_json in a string field
            revision_data['content'] = json.dumps(content_data)
            revision.content_json = json.dumps(
                revision_data, cls = DjangoJSONEncoder)
            revision.save()

   class Migration(migrations.Migration):
      dependencies = [
         ('home', '0021_auto_20220218_1943'),
      ]
      operations = [
         migrations.RunPython(forwards, forwards),
      ]

Gestió de paquets python

Per tenir ben controlades les dependències, versions en diversos entorns, et recomane utilitzar una instal·lació de python autocontinguda i independent. La utilitat pipenv facilita molt el manteniment dels mòduls python fent-ho completament independent de la instal·lació del sistema, assegurant que hi ha els mòduls necessaris per a l'aplicació i amb la versió requerida.

Internacionalització

No teniu secrets, utilitzo el mòdul wagtail-localize seguint la documentació. En el moment de construir aquest site vaig haver de fer servir la versió 1.1rc1 buscant la compatibilitat amb la darrera versió de wagtail disponible. Si fos així en el teu cas, necessitaries instal·lar-ho així:

 pipenv install \
    -e git+https://github.com/wagtail/wagtail-localize.git@v1.1rc1#egg=wagtail-localize"

D'altra banda, a la integració del bloc m'he trobat amb un problema en un mètode de la classe EntryPageServe de puput. Com que no és bo corregir-ho directament a les fonts, decideixo sobreescriure el mètode afectat en execució d'aquesta forma. Fixa't a l'última línia:

 from django.http import Http404, HttpResponse
 from wagtail.core import hooks
 from wagtail.core.models import Site
 from puput.utils import strip_prefix_and_ending_slash, import_model
 from django.views.decorators.csrf import ensure_csrf_cookie
 from django.utils.decorators import method_decorator
 from puput.views import EntryPageServe

 @method_decorator(ensure_csrf_cookie)
 def get(self, request, *args, **kwargs):
        site = Site.find_for_request(request)
        if not site:
            raise Http404
        if request.resolver_match.url_name == 'entry_page_serve_slug':
            splited_path = strip_prefix_and_ending_slash(request.path).split("/")
            path_components = splited_path[:-4] + splited_path[-1:]
        else:
            path_components = [strip_prefix_and_ending_slash(request.path).split('/')[-1]]
        #page, args, kwargs = site.root_page.specific.route(request, path_components) <<-----
        page, args, kwargs = site.root_page.localized.specific.route(request, path_components)
        for fn in hooks.get_hooks('before_serve_page'):
            result = fn(page, request, args, kwargs)
            if isinstance(result, HttpResponse):
                return result
        return page.serve(request, *args, **kwargs)

 setattr(EntryPageServe,'get',get)

Galeria d'astrofotografia o com estendre el model de puput

Afortunadament puput permet estendre el model de pàgines amb molta facilitat sense haver de reescriure gaire codi ni tocar res de l'original.

D'una banda m'escric la meva classe abstracta d'entrada de bloc, afegint un nou camp del tipus StreamBlock:

   class ContentBlock(StreamBlock):
      heading = blocks.CharBlock(form_classname="full title")
      paragraph = blocks.RichTextBlock()
      markdown = MarkdownBlock(icon="code")
      image = ImageChooserBlock()

   class MyEntryAbstract(EntryAbstract):
      body = RichTextField(verbose_name=_('body'),blank=True)
      content = StreamField(ContentBlock(),blank=True)
      EntryAbstract.content_panels = [
         MultiFieldPanel(
            [        
               FieldPanel('title', classname="title"),
               ImageChooserPanel('header_image'),
               FieldPanel('excerpt', classname="full"),
               FieldPanel('body', classname="full"),
               StreamFieldPanel('content'),
            ],       
            heading=_("Content")
         ),    
      ] + EntryAbstract.content_panels[1:]

      class Meta:
         abstract = True

Ens podem oblidar, tal com indica la documentació, afegir les següents línies a l'arxiu de propietats settings/base.py de l'entorn django on correrà la web. D'aquesta manera la classe Entry heretarà els mètodes deMyEntryAbstract:

    MIGRATION_MODULES = {'puput': 'blog.puput_migrations'}
    PUPUT_ENTRY_MODEL = 'blog.abstracts.MyEntryAbstract'

D'altra banda escric una nova classe filla de l'EntryPage de puput, heretant els mètodes de la classe abstracta anterior, tal com hem explicat.

  class TakenDate(Orderable):
      taken_date =  models.DateField(blank=True);
      page = ParentalKey(
         'AstrophotoEntryPage',
         on_delete=models.CASCADE,
         related_name='taken_dates',
      )
      panels = [
         FieldPanel('taken_date')
      ]

   class TimeUnitsChoicesIterable(object):
      def __iter__(self):
         tagchoices = [('s','Seconds'),('ms','miliSeconds'),('fps','Frames per Second')]
         return tagchoices.__iter__()

   class TakenFrames(models.Model):
      filter =  models.CharField(max_length=20, blank=False)
      frames =  models.PositiveSmallIntegerField(blank=False)
      time =  models.PositiveSmallIntegerField(blank=False)
      time_unit =  models.CharField(max_length=3, blank=False, default="s")
      temperature =  models.IntegerField(blank=True,null=True)
      page = ParentalKey(
         'AstrophotoEntryPage',
         on_delete=models.CASCADE,
         related_name='taken_frames',
      )
      timeunit_select = forms.Select()
      timeunit_select.choices = TimeUnitsChoicesIterable()
      panels = [
         FieldPanel('filter'),
         FieldPanel('frames'),
         FieldPanel('time'),
         FieldPanel('time_unit',widget=timeunit_select),
         FieldPanel('temperature'),
      ]        

   class AstrophotoEntryPage(EntryPage):
      camera = models.CharField(max_length=80, blank=True)
      filters = models.CharField(max_length=200, blank=True)
      telescope = models.CharField(max_length=80, blank=True)
      mount = models.CharField(max_length=80, blank=True)
      guiding = models.CharField(max_length=200, blank=True)
      other = models.CharField(max_length=400, blank=True)
      software = models.CharField(max_length=200, blank=True)
      site = models.CharField(max_length=80, blank=True)
      content_panels = EntryPage.content_panels[:1] + [
         MultiFieldPanel([              
          InlinePanel('taken_dates', min_num=1, max_num=6, label="Dates"),
          InlinePanel('taken_frames', label="Frames"),
          FieldPanel('site'),
         ], heading="Adquisition Details"),
         MultiFieldPanel([
          FieldPanel('camera'),
          FieldPanel('filters'),
          FieldPanel('telescope'),
          FieldPanel('mount'),
          FieldPanel('guiding'),
          FieldPanel('other'),
          FieldPanel('software'),
         ], heading="Equipment"),
      ] + EntryPage.content_panels[1:]

   BlogPage.subpage_types.append(AstrophotoEntryPage)

Aquesta darrera línia de codi és fonamental, si vull afegir aquest nou tipus de post, a més de l'original.

Carousel d'entrades del bloc amb puput

Una de les característiques que buscava era la de poder mostrar carousels d'imatges i de categories del bloc. Per això construirem diversos template tag blog/templatetags/blog_tags.py:

 from importlib import import_module
 from django.template import Library
 from django.urls import resolve
 from django.template.defaultfilters import urlencode
 from django.template.loader import render_to_string
 from django_social_share.templatetags.social_share import _build_url
 from el_pagination.templatetags.el_pagination_tags import show_pages, paginate
 from puput.utils import import_model
 from puput.models import Category, Tag
 from wagtail.core.models import Site, Locale
 import uuid

 register = Library()

 @register.inclusion_tag('puput/tags/my_entries_list.html', takes_context=True)
 def my_recent_entries(context, limit=None, page=None, request=None):
    blog_page_m = import_module('puput.models').BlogPage
    locale_id=context['page'].locale.id
    blog_page = blog_page_m.objects.filter(locale=locale_id).first()
    context['blog_page'] = blog_page
    context['carousel_id'] = str(uuid.uuid4())
    entries = blog_page.get_entries().exclude(header_image__isnull=True).order_by('-date')
    if limit:
        entries = entries[:limit]
    context['entries'] = entries
    return context

 @register.inclusion_tag('puput/tags/my_entries_list.html', takes_context=True)
 def my_entries_by_category(context, category, limit=None):
    blog_page_m = import_module('puput.models').BlogPage
    locale_id=context['page'].locale.id
    blog_page = blog_page_m.objects.filter(locale=locale_id).first()
    context['blog_page'] = blog_page
    context['carousel_id'] = str(uuid.uuid4())
    entries = blog_page.get_entries().filter(entry_categories__category__name=category)
    entries = entries.exclude(header_image__isnull=True).order_by('-date')
    if limit:
        entries = entries[:limit]
    context['entries'] = entries
    return context

 @register.inclusion_tag('puput/tags/my_entries_list.html', takes_context=True)
 def my_entries_by_tag(context, label, limit=None):
    blog_page_m = import_module('puput.models').BlogPage
    locale_id=context['page'].locale.id
    blog_page = blog_page_m.objects.filter(locale=locale_id).first()
    context['blog_page'] = blog_page
    context['carousel_id'] = str(uuid.uuid4())
    entries = blog_page.get_entries().filter(tags__name=label)
    entries = entries.exclude(header_image__isnull=True).order_by('-date')
    if limit:  
        entries = entries[:limit]
    context['entries'] = entries
    return context

amb el seu corresponent template puput/tags/my_entries_list.html per a la presentació:

 {% load i18n wagtailimages_tags puput_tags %}

 <div id="mycarousel_{{ carousel_id }}" class="carousel slide mycarousel-{{self.carousel_type}} {%if self.label%}blog-carousel-{{self.label}}{%endif%}" data-ride="carousel">
    <div class="carousel-inner">
        {% for entry in entries %}
            {% image entry.header_image fill-800x450 as carousel_img %}
            <div class="carousel-item {% if forloop.first %}active{% endif %}">
              <a href="{% entry_url entry blog_page %}">
                 <img src="{{ carousel_img.url }}" class="d-block w-100" alt="{{ carousel_img.alt }}">
              </a>
              <div class="carousel-caption">
                <h3>{{ entry.title }}</h3>
                <p>{{ entry.date|date:"SHORT_DATE_FORMAT" }}</p>
              </div>
            </div>
        {% endfor %}
    </div>
  <a class="carousel-control-prev" href="#mycarousel_{{ carousel_id }}" role="button" data-slide="prev">
    <span class="carousel-control-prev-icon"></span>
  </a>
  <a class="carousel-control-next" href="#mycarousel_{{ carousel_id }}" role="button"  data-slide="next">
    <span class="carousel-control-next-icon"></span>
  </a>
</div>

D'aquesta manera, podem incloure el carousel en un temperat normal, quedant així:

   <section>
      <h3>{% trans "Blog Last Entries" %}</h3>
      {% my_recent_entries 10 %}
      <h3>Category Entries</h3>
      {% my_entries_by_category "Astronomy" 5 %}
      <h3>Tags Entries</h3>
      {% my_entries_by_tag "Planetary" 5 %}
   </section>

Per acabar, així quedaria un StructBlock amb carousel d'últimes entrades del bloc,

 class CarouselBlogChoiceBlock(blocks.ChoiceBlock):
    choices = (
        ('all', 'Recent entries'),
        ('tagged', 'Recent tagged entries'),
        ('categories', 'Recent categories entries'),
    )
 class AlignChoiceBlock(blocks.ChoiceBlock):
    choices = (
        ('left', 'Float Left'),
        ('right', 'Float Right'),
        ('center', 'Center'),
    )
 class CarouselBlogBlock(StructBlock):
    carousel_type = AlignChoiceBlock(label='Carousel Type', default='center')
    carousel_blog_type = CarouselBlogChoiceBlock(label='Entry Filter', default='all')
    label = CharBlock(max_length=80)
    limit = IntegerBlock(min_value=5, max_value=20, default=5)
    class Meta:
        icon = "cog"
        template = "streamfieldblocks/carousel_blog_block.html"

quedant el templat carousel_blog_block.html d'aquesta manera,

 {% load wagtailimages_tags wagtailcore_tags blog_tags %}
 {% if self.carousel_blog_type == 'all' %}
   {% my_recent_entries limit page request%}
 {% elif self.carousel_blog_type == 'categories' %}
   {% my_entries_by_category self.label self.limit %}
 {% elif self.carousel_blog_type == 'tagged' %}
   {% my_entries_by_tag self.label self.limit %}
 {% endif %}

Suport LaTeX per a fórmules matemàtiques i acoloriment de codi

El suport per a $\LaTeX$ ho vam fer usant javascript gràcies a la biblioteca MathJax, una mica de codi de configuració al template de base

   <script type="text/x-mathjax-config">
   MathJax.Hub.Config({
      extensions: ["tex2jax.js"],
      jax: ["input/TeX", "output/HTML-CSS"],
      tex2jax: {
      inlineMath: [['$','$']],
      displayMath: [['$$','$$']] ,
      processEscapes: true
      }, 
      "HTML-CSS": { availableFonts: ["TeX"] }
   });
   </script>
   ...
   <script type="text/javascript" 
      src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-AMS-MML-AM_CHTML">
   </script>

i una mica de codi d'integració amb markdown on a més afegim suport per codehilite i així donar una mica de color al codi, mysite/md_converter/utils.py:

   import markdown

   def render_markdown(value):
   html = markdown.markdown(
      value,
      extensions=[
         'extra',
         'codehilite',
         'mysite.md_converter.mdx.mdx_mathjax',
      ],
      extension_configs={
         'codehilite': [
          ('guess_lang', False),
         ]
      },
      output_format='html5'
   )
   return html

Aquest seria el codi de l'extensió, prenent com a base python-markdown-mathjax i tenint en compte aquest fil de stackoverflow, mysite/md_converter/mdx/mdx_mathjax.py:```python import markdown
import html

class MathJaxPattern(markdown.inlinepatterns.Pattern): def init(self, md): markdown.inlinepatterns.Pattern.init( self, r'(?<!\)(\$\$?)(.+?)\2', md)

  def handleMatch(self, m):
     text = html.escape(m.group(2) + m.group(3) + m.group(2))
     return self.markdown.htmlStash.store(text)

class MathJaxExtension(markdown.Extension): def init(self, kwargs): super(MathJaxExtension, self).init(kwargs)

  def extendMarkdown(self, md, md_globals):
     md.inlinePatterns.add('mathjax', MathJaxPattern(md), '<escape')

def makeExtension(configs={}):
  return MathJaxExtension(**configs)
También habría que añadir el siguiente templatetag
```python
   from django import template
   from mysite.md_converter.utils import render_markdown

   register = template.Library()

   @register.filter(name='markdown')
   def markdown(value):
      return render_markdown(value)

per usar-lo com a filtre als templates principals de html:

  ...
      </div>
  {% elif iner_block.block_type == 'markdown' %}
      <div class="block-{{ iner_block.block_type }}">
          {{ iner_block.value|markdown|safe }}
      </div>
  {% elif iner_block.block_type == 'carousel_blog' %}
  ...

i un cop configurat tot, a tall d'exemple, aquest seria el resultat: \begin{equation} \begin{aligned} \frac{\partial \vec {\mathcal D}}{\partial t} & = \nabla \times \vec{\mathcal H} \\ \frac{\partial \vec {\mathcal B}}{\partial t} & = -\nabla \times \vec{\mathcal E} \\ \nabla \cdot \vec {\mathcal B} & = 0 \\ \nabla \cdot \vec {\mathcal D} & = 0 \end{aligned} \end{equation}

o aquesta forma, que m'agrada més

\begin{equation} \begin{aligned} \nabla \times \vec{\mathcal B} & = \mu_0 \left( \vec {\mathcal J} + \varepsilon_0 \frac{\partial \vec {\mathcal E}}{\partial t} \right) \\ \nabla \times \vec{\mathcal E} & = - \frac{\partial \vec {\mathcal B}}{\partial t} \\ \nabla \cdot \vec {\mathcal B} & = 0 \\ \nabla \cdot \vec {\mathcal E} & = \frac{\rho}{\varepsilon_0} \end{aligned} \end{equation}

si ens fixem en el codi html generat veiem que una estructura xml MathML, de manera que navegadors que el suporten puguin renderitzar les fórmules directament.

Avís de cookies

Estic d'acord Aquest lloc web guarda petits fragments d'informació (cookies) al vostre dispositiu només amb una finalitat funcional. Són cookies tècniques i de personalització. No guardem informació confidencial ni tracem activitatde navegació ni les usem per a publicitat. Les cookies usades per aquest lloc queden excloses de l'àmbit d'aplicació de l'article 22.2 de la LSSI. No obstant això, podeu desactivar l'ús de cookies modificant la configuració del vostre navegador.