Python >> Tutoriel Python >  >> Python

Utiliser Markdown dans Django

En tant que développeurs, nous nous appuyons sur des outils d'analyse statique pour vérifier, pelucher et transformer notre code. Nous utilisons ces outils pour nous aider à être plus productifs et à produire un meilleur code. Cependant, lorsque nous écrivons du contenu à l'aide de démarques, les outils à notre disposition sont rares.

Dans cet article, nous décrivons comment nous avons développé une extension Markdown pour relever les défis de la gestion de contenu à l'aide de Markdown dans les sites Django.

Le problème

Comme tous les sites Web, nous avons différents types de contenu (principalement) statique dans des endroits comme notre page d'accueil, la section FAQ et la page "À propos". Pendant très longtemps, nous avons géré tout ce contenu directement dans les templates Django.

Lorsque nous avons finalement décidé qu'il était temps de déplacer ce contenu hors des modèles et dans la base de données, nous avons pensé qu'il était préférable d'utiliser Markdown. Il est plus sûr de produire du HTML à partir de Markdown, il offre un certain niveau de contrôle et d'uniformité et est plus facile à manipuler pour les utilisateurs non techniques. Au fur et à mesure que nous progressions dans le déménagement, nous avons remarqué qu'il nous manquait quelques éléments :

Liens internes

Les liens vers les pages internes peuvent être rompus lorsque l'URL change. Dans les modèles et vues Django, nous utilisons reverse et {% url %} , mais cela n'est pas disponible dans Markdown ordinaire.

Copier entre les environnements

Les liens internes absolus ne peuvent pas être copiés entre les environnements. Cela peut être résolu en utilisant des liens relatifs, mais il n'y a aucun moyen de l'appliquer immédiatement.

Liens invalides

Les liens non valides peuvent nuire à l'expérience utilisateur et amener l'utilisateur à remettre en question la fiabilité de l'ensemble du contenu. Ce n'est pas quelque chose qui est unique à Markdown, mais les modèles HTML sont maintenus par des développeurs qui connaissent une chose ou deux sur les URL. Les documents Markdown, quant à eux, sont destinés aux rédacteurs non techniques.

Travail antérieur

Lorsque je faisais des recherches sur ce problème, j'ai recherché des linters Python, un préprocesseur Markdown et des extensions pour aider à produire un meilleur Markdown. J'ai trouvé très peu de résultats. Une approche qui s'est démarquée consistait à utiliser des modèles Django pour produire des documents Markdown.

Prétraiter Markdown à l'aide du modèle Django

En utilisant les modèles Django, vous pouvez utiliser des balises de modèle telles que url pour inverser les noms d'URL, ainsi que les conditions, les variables, les formats de date et toutes les autres fonctionnalités du modèle Django. Cette approche utilise essentiellement le modèle Django comme préprocesseur pour les documents Markdown.

J'ai personnellement pensé que ce n'était peut-être pas la meilleure solution pour les rédacteurs non techniques. De plus, je craignais que l'accès aux balises de modèle Django ne soit dangereux.

Utiliser Markdown

Avec une meilleure compréhension du problème, nous étions prêts à creuser un peu plus dans Markdown en Python.

Convertir Markdown en HTML

Pour commencer à utiliser Markdown en Python, installez le markdown paquet :

$ pip install markdown
Collecting markdown
Installing collected packages: markdown
Successfully installed markdown-3.2.1

Ensuite, créez un Markdown objet et utilisez la fonction convert pour transformer du Markdown en HTML :

>>> import markdown
>>> md = markdown.Markdown()
>>> md.convert("My name is **Haki**")
<p>My name is <strong>Haki</strong></p>

Vous pouvez maintenant utiliser cet extrait HTML dans votre modèle.

Utilisation des extensions Markdown

Le processeur Markdown de base fournit l'essentiel pour produire du contenu HTML. Pour des options plus "exotiques", le Python markdown package comprend des extensions intégrées. Une extension populaire est l'extension "extra" qui ajoute, entre autres, la prise en charge des blocs de code clôturé :

>>> import markdown
>>> md = markdown.Markdown(extensions=['extra'])
>>> md.convert("""```python
... print('this is Python code!')
... ```""")
<pre><code class="python">print(\'this is Python code!\')\n</code></pre>

Pour étendre Markdown avec nos capacités Django uniques, nous allons développer notre propre extension.

Si vous regardez la source, vous verrez que pour convertir le markdown en HTML, Markdown utilise différents processeurs. Un type de processeur est un processeur en ligne. Les processeurs en ligne correspondent à des modèles en ligne spécifiques tels que les liens, les backticks, le texte en gras et le texte souligné, et les convertissent en HTML.

L'objectif principal de notre extension Markdown est de valider et de transformer les liens. Ainsi, le processeur en ligne qui nous intéresse le plus est le LinkInlineProcessor . Ce processeur prend la démarque sous la forme de [Haki's website](https://hakibenita.com) , l'analyse et renvoie un tuple contenant le lien et le texte.

Pour étendre la fonctionnalité, nous étendons LinkInlineProcessor et créer un Markdown.Extension qui l'utilise pour gérer les liens :

import markdown
from markdown.inlinepatterns import LinkInlineProcessor, LINK_RE


def get_site_domain() -> str:
 # TODO: Get your site domain here
 return 'example.com'


def clean_link(href: str, site_domain: str) -> str:
 # TODO: This is where the magic happens!
 return href


class DjangoLinkInlineProcessor(LinkInlineProcessor):
 def getLink(self, data, index):
 href, title, index, handled = super().getLink(data, index)
 site_domain = get_site_domain()
 href = clean_link(href, site_domain)
 return href, title, index, handled


class DjangoUrlExtension(markdown.Extension):
 def extendMarkdown(self, md, *args, **kwrags):
 md.inlinePatterns.register(DjangoLinkInlineProcessor(LINK_RE, md), 'link', 160)

Décomposons-le :

  • L'extension DjangoUrlExtension enregistre un processeur de lien en ligne appelé DjangoLinkInlineProcessor . Ce processeur remplacera tout autre processeur de lien existant.
  • Le processeur en ligne DjangoLinkInlineProcessor étend le LinkInlineProcessor intégré , et appelle la fonction clean_link sur chaque lien qu'il traite.
  • La fonction clean_link reçoit un lien et un domaine, et renvoie un lien transformé. C'est ici que nous allons brancher notre implémentation.

Comment obtenir le domaine du site

Pour identifier les liens vers votre propre site, vous devez connaître le domaine de votre site. Si vous utilisez le framework de sites de Django, vous pouvez l'utiliser pour obtenir le domaine actuel.

Je ne l'ai pas inclus dans mon implémentation car nous n'utilisons pas le framework sites. Au lieu de cela, nous définissons une variable dans les paramètres de Django.

Une autre façon d'obtenir le domaine actuel est à partir d'un HttpRequest objet. Si le contenu n'est modifié que sur votre propre site, vous pouvez essayer de connecter le domaine du site à partir de l'objet de requête. Cela peut nécessiter quelques modifications dans la mise en œuvre.

Pour utiliser l'extension, ajoutez-la lorsque vous initialisez un nouveau Markdown instance :

>>> md = markdown.Markdown(extensions=[DjangoUrlExtension()])
>>> md.convert("[haki's site](https://hakibenita.com)")
<p><a href="https://hakibenita.com">haki\'s site</a></p>

Génial, l'extension est en cours d'utilisation et nous sommes prêts pour la partie intéressante !

Maintenant que nous avons l'extension pour appeler le clean_link sur tous les liens, nous pouvons implémenter notre logique de validation et de transformation.

Pour lancer le bal, nous allons commencer par une simple validation. mailto les liens sont utiles pour ouvrir le client de messagerie de l'utilisateur avec une adresse de destinataire, un objet et même un corps de message prédéfinis.

Un mailto commun le lien peut ressembler à ceci :

<a href="mailto:[email protected]?subject=I need help!">Help!</a>

Ce lien ouvrira votre client de messagerie configuré pour composer un nouvel e-mail à "[email protected]" avec la ligne d'objet "J'ai besoin d'aide !".

mailto les liens ne doivent pas nécessairement inclure une adresse e-mail. Si vous regardez les boutons "partager" au bas de cet article, vous trouverez un mailto lien qui ressemble à ceci :

<a
 href="mailto:?subject=Django Markdown by Haki Benita&body=http://hakibenita.com/django-markdown"
 title="Email">
 Share via Email
</a>

Ce mailto le lien n'inclut pas de destinataire, juste une ligne d'objet et le corps du message.

Maintenant que nous avons une bonne compréhension de ce que mailto les liens ressemblent, nous pouvons ajouter la première validation au clean_link fonction :

from typing import Optional
import re

from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator


class Error(Exception):
 pass


class InvalidMarkdown(Error):
 def __init__(self, error: str, value: Optional[str] = None) -> None:
 self.error = error
 self.value = value

 def __str__(self) -> str:
 if self.value is None:
 return self.error
 return f'{self.error} "{self.value}"';


def clean_link(href: str, site_domain: str) -> str:
 if href.startswith('mailto:'):
 email_match = re.match('^(mailto:)?([^?]*)', href)
 if not email_match:
 raise InvalidMarkdown('Invalid mailto link', value=href)

 email = email_match.group(2)
 if email:
 try:
 EmailValidator()(email)
 except ValidationError:
 raise InvalidMarkdown('Invalid email address', value=email)

 return href

 # More validations to come...

 return href

Pour valider un mailto lien, nous avons ajouté le code suivant à clean_link :

  • Vérifiez si le lien commence par mailto: pour identifier les liens pertinents.
  • Divisez le lien vers ses composants à l'aide d'une expression régulière.
  • Retirer l'adresse e-mail réelle du mailto lien, et validez-le en utilisant le EmailValidator de Django .

Notez que nous avons également ajouté un nouveau type d'exception appelé InvalidMarkdown . Nous avons défini notre propre Exception personnalisé type pour le distinguer des autres erreurs générées par markdown lui-même.

Classe d'erreur personnalisée

J'ai déjà écrit sur les classes d'erreurs personnalisées, pourquoi elles sont utiles et quand les utiliser.

Avant de poursuivre, ajoutons quelques tests et voyons ceci en action :

>>> md = markdown.Markdown(extensions=[DjangoUrlExtension()])
>>> md.convert("[Help](mailto:[email protected]?subject=I need help!)")
'<p><a href="mailto:[email protected]?subject=I need help!">Help</a></p>'

>>> md.convert("[Help](mailto:?subject=I need help!)")
<p><a href="mailto:?subject=I need help!">Help</a></p>

>>> md.convert("[Help](mailto:invalidemail?subject=I need help!)")
InvalidMarkdown: Invalid email address "invalidemail"

Super! A fonctionné comme prévu.

Maintenant que nous nous sommes mouillés les orteils avec mailto liens, nous pouvons gérer d'autres types de liens :

Liens externes

  • Liens en dehors de notre application Django.
  • Doit contenir un schéma :http ou https.
  • Idéalement, nous voulons également nous assurer que ces liens ne sont pas rompus, mais nous ne le ferons pas maintenant.

Liens internes

  • Liens vers des pages de notre application Django.
  • Le lien doit être relatif :cela nous permettra de déplacer le contenu entre les environnements.
  • Utilisez les noms d'URL de Django au lieu d'un chemin d'URL :cela nous permettra de déplacer les vues en toute sécurité sans nous soucier des liens rompus dans le contenu démarqué.
  • Les liens peuvent contenir des paramètres de requête (? ) et un fragment (# ).

Référencement

D'un point de vue SEO, les URL publiques ne doivent pas changer. Lorsqu'ils le font, vous devez les gérer correctement avec les redirections, sinon vous pourriez être pénalisé par les moteurs de recherche.

Avec cette liste d'exigences, nous pouvons commencer à travailler.

Résoudre les noms d'URL

Pour créer un lien vers des pages internes, nous souhaitons que les rédacteurs fournissent un nom d'URL , pas un chemin d'URL . Par exemple, disons que nous avons cette vue :

from django.urls import path
from app.views import home

urlpatterns = [
 path('', home, name='home'),
]

Le chemin URL vers cette page est https://example.com/ , le nom de l'URL est home . Nous voulons utiliser le nom d'URL home dans nos liens de démarquage, comme ceci :

Go back to [homepage](home)

Cela devrait rendre :

<p>Go back to <a href="/">homepage</a></p>

Nous souhaitons également prendre en charge les paramètres de requête et le hachage :

Go back to [homepage](home#top)
Go back to [homepage](home?utm_source=faq)

Cela devrait s'afficher dans le code HTML suivant :

<p>Go back to <a href="/#top">homepage</a></p>
<p>Go back to <a href="/?utm_source=faq">homepage</a></p>

En utilisant les noms d'URL, si nous modifions le chemin de l'URL, les liens dans le contenu ne seront pas rompus. Pour vérifier si le href fourni par le rédacteur est un url_name valide , nous pouvons essayer de reverse il :

>>> from django.urls import reverse
>>> reverse('home')
'/'

Le nom d'URL "home" pointe vers le chemin d'URL "/". Lorsqu'il n'y a pas de correspondance, une exception est levée :

>>> from django.urls import reverse
>>> reverse('foo')
NoReverseMatch: Reverse for 'foo' not found.
'foo' is not a valid view function or pattern name.

Avant de continuer, que se passe-t-il lorsque le nom de l'URL inclut des paramètres de requête ou un hachage :

>>> from django.urls import reverse
>>> reverse('home#top')
NoReverseMatch: Reverse for 'home#top' not found.
'home#top' is not a valid view function or pattern name.

>>> reverse('home?utm_source=faq')
NoReverseMatch: Reverse for 'home?utm_source=faq' not found.
'home?utm_source=faq' is not a valid view function or pattern name.

Cela a du sens car les paramètres de requête et le hachage ne font pas partie du nom de l'URL.

Pour utiliser reverse et prennent en charge les paramètres de requête et les hachages, nous devons d'abord nettoyer la valeur. Ensuite, vérifiez qu'il s'agit d'un nom d'URL valide et renvoyez le chemin de l'URL, y compris les paramètres de requête et le hachage, le cas échéant :

import re
from django.urls import reverse

def clean_link(href: str, site_domain: str) -> str:
 # ... Same as before ...

 # Remove fragments or query params before trying to match the URL name.
 href_parts = re.search(r'#|\?', href)
 if href_parts:
 start_ix = href_parts.start()
 url_name, url_extra = href[:start_ix], href[start_ix:]
 else:
 url_name, url_extra = href, ''

 try:
 url = reverse(url_name)
 except NoReverseMatch:
 pass
 else:
 return url + url_extra

 return href

Cet extrait utilise une expression régulière pour diviser href dans l'occurrence de ? ou # , et renvoyez les pièces.

Assurez-vous que cela fonctionne :

>>> md = markdown.Markdown(extensions=[DjangoUrlExtension()])
>>> md.convert("Go back to [homepage](home)")
<p>Go back to <a href="/">homepage</a></p>

>>> md.convert("Go back to [homepage](home#top)")
<p>Go back to <a href="/#top">homepage</a></p>

>>> md.convert("Go back to [homepage](home?utm_source=faq)")
<p>Go back to <a href="/?utm_source=faq">homepage</a></p>

>>> md.convert("Go back to [homepage](home?utm_source=faq#top)")
<p>Go back to <a href="/?utm_source=faq#top">homepage</a></p>

Étonnante! Les rédacteurs peuvent désormais utiliser des noms d'URL dans Markdown. Ils peuvent également inclure des paramètres de requête et un fragment à ajouter à l'URL.

Pour gérer correctement les liens externes, nous voulons vérifier deux choses :

  1. Les liens externes fournissent toujours un schéma, soit http: ou https: .
  2. Empêcher les liens absolus vers notre propre site. Les liens internes doivent utiliser des noms d'URL.

Jusqu'à présent, nous avons géré les noms d'URL et mailto liens. Si nous avons réussi ces deux vérifications, cela signifie href est une URL. Commençons par vérifier si le lien pointe vers notre propre site :

from urllib.parse import urlparse

def clean_link(href: str, site_domain: str) -> str:
 parsed_url = urlparse(href)
 if parsed_url.netloc == site_domain:
 # TODO: URL is internal.

La fonction urlparse renvoie un tuple nommé qui contient les différentes parties de l'URL. Si le netloc la propriété est égale à site_domain , le lien est vraiment un lien interne.

Si l'URL est en fait interne, nous devons échouer. Mais gardez à l'esprit que les rédacteurs ne sont pas nécessairement des techniciens, nous voulons donc les aider un peu et fournir un message d'erreur utile. Nous exigeons que les liens internes utilisent un nom d'URL et non un chemin d'URL. Il est donc préférable d'informer les rédacteurs du nom d'URL du chemin qu'ils ont fourni.

Pour obtenir le nom d'URL d'un chemin d'URL, Django fournit une fonction appelée resolve :

>>> from django.utils import resolve
>>> resolve('/')
ResolverMatch(
 func=app.views.home,
 args=(),
 kwargs={},
 url_name=home,
 app_names=[],
 namespaces=[],
 route=,
)
>>> resolve('/').url_name
'home'

Lorsqu'une correspondance est trouvée, resolve renvoie un ResolverMatch objet qui contient, entre autres informations, le nom de l'URL. Lorsqu'une correspondance n'est pas trouvée, une erreur est générée :

>>> resolve('/foo')
Resolver404: {'tried': [[<URLPattern '' [name='home']>]], 'path': 'foo'}

C'est en fait ce que Django fait sous le capot pour déterminer quelle fonction de vue exécuter lorsqu'une nouvelle requête arrive.

Pour fournir aux rédacteurs de meilleurs messages d'erreur, nous pouvons utiliser le nom d'URL du ResolverMatch objet :

from urllib.parse import urlparse

def clean_link(href: str, site_domain: str) -> str:
 # ...

 parsed_url = urlparse(href)
 if parsed_url.netloc == site_domain:
 try:
 resolver_match = resolve(parsed_url.path)
 except Resolver404:
 raise InvalidMarkdown(
 "Should not use absolute links to the current site.\n"
 "We couldn't find a match to this URL. Are you sure it exists?",
 value=href,
 )
 else:
 raise InvalidMarkdown(
 "Should not use absolute links to the current site.\n"
 'Try using the url name "{}".'.format(resolver_match.url_name),
 value=href,
 )

 return href

Lorsque nous identifions que le lien est interne, nous traitons deux cas :

  • Nous ne reconnaissons pas l'URL :l'URL est probablement incorrecte. Demandez au rédacteur de vérifier si l'URL contient des erreurs.
  • Nous reconnaissons l'URL :l'URL est correcte. Indiquez donc au rédacteur quel nom d'URL utiliser à la place.

Voyons-le en action :

>>> clean_link('https://example.com/', 'example.com')
InvalidMarkdown: Should not use absolute links to the current site.
Try using the url name "home". "https://example.com/"

>>> clean_link('https://example.com/foo', 'example.com')
InvalidMarkdown: Should not use absolute links to the current site.
We couldn't find a match to this URL.
Are you sure it exists? "https://example.com/foo"

>>> clean_link('https://external.com', 'example.com')
'https://external.com'

Agréable! Les liens externes sont acceptés et les liens internes sont rejetés avec un message utile.

Schéma requis

La dernière chose que nous voulons faire est de nous assurer que les liens externes incluent un schéma, soit http: ou https: . Ajoutons ce dernier élément à la fonction clean_link :

def clean_link(href: str, site_domain: str) -> str:
 # ...
 parsed_url = urlparse(href)

 #...
 if parsed_url.scheme not in ('http', 'https'):
 raise InvalidMarkdown(
 'Must provide an absolute URL '
 '(be sure to include https:// or http://)',
 href,
 )

 return href

En utilisant l'URL analysée, nous pouvons facilement vérifier le schéma. Assurons-nous que cela fonctionne :

>>> clean_link('external.com', 'example.com')
InvalidMarkdown: Must provide an absolute URL (be sure to include https:// or http://) "external.com"

Nous avons fourni à la fonction un lien sans schéma, et elle a échoué avec un message utile. Cool !

Mettre tout ensemble

Ceci est le code complet pour le clean_link fonction :

def clean_link(href: str, site_domain: str) -> str:
 if href.startswith('mailto:'):
 email_match = re.match(r'^(mailto:)?([^?]*)', href)
 if not email_match:
 raise InvalidMarkdown('Invalid mailto link', value=href)

 email = email_match.groups()[-1]
 if email:
 try:
 EmailValidator()(email)
 except ValidationError:
 raise InvalidMarkdown('Invalid email address', value=email)

 return href

 # Remove fragments or query params before trying to match the url name
 href_parts = re.search(r'#|\?', href)
 if href_parts:
 start_ix = href_parts.start()
 url_name, url_extra = href[:start_ix], href[start_ix:]
 else:
 url_name, url_extra = href, ''

 try:
 url = reverse(url_name)
 except NoReverseMatch:
 pass
 else:
 return url + url_extra

 parsed_url = urlparse(href)

 if parsed_url.netloc == site_domain:
 try:
 resolver_match = resolve(parsed_url.path)
 except Resolver404:
 raise InvalidMarkdown(
 "Should not use absolute links to the current site.\n"
 "We couldn't find a match to this URL. Are you sure it exists?",
 value=href,
 )
 else:
 raise InvalidMarkdown(
 "Should not use absolute links to the current site.\n"
 'Try using the url name "{}".'.format(resolver_match.url_name),
 value=href,
 )

 if parsed_url.scheme not in ('http', 'https'):
 raise InvalidMarkdown(
 'Must provide an absolute URL '
 '(be sure to include https:// or http://)',
 href,
 )

 return href

Pour avoir une idée de ce à quoi ressemble un cas d'utilisation réel pour toutes ces fonctionnalités, jetez un œil au contenu suivant :

# How to Get Started?

Download the [mobile app](https://some-app-store.com/our-app) and log in to your account.
If you don't have an account yet, [sign up now](signup?utm_source=getting_started).
For more information about pricing, check our [pricing plans](home#pricing-plans)

Cela produira le code HTML suivant :

<h1>How to Get Started?</h1>
<p>Download the <a href="https://some-app-store.com/our-app">mobile app</a> and log in to your account.
If you don't have an account yet, <a href="signup/?utm_source=getting_started">sign up now</a>.
For more information about pricing, check our <a href="/#pricing-plans">pricing plans</a></p>

Génial !

Conclusion

Nous avons maintenant une jolie extension qui peut valider et transformer les liens dans les documents Markdown ! Il est maintenant beaucoup plus facile de déplacer des documents entre les environnements et de garder notre contenu bien rangé et surtout, correct et à jour !

Source

Le code source complet peut être trouvé dans ce gist.

Aller plus loin

Les fonctionnalités décrites dans cet article ont bien fonctionné pour nous, mais vous voudrez peut-être les ajuster pour répondre à vos propres besoins.

Si vous avez besoin d'idées, en plus de cette extension, nous avons également créé un préprocesseur Markdown qui permet aux rédacteurs d'utiliser des constantes dans Markdown. Par exemple, nous avons défini une constante appelée SUPPORT_EMAIL , et nous l'utilisons comme ceci :

Contact our support at [$SUPPORT_EMAIL](mailto:$SUPPORT_EMAIL)

Le préprocesseur remplacera la chaîne $SUPPORT_EMAIL avec le texte que nous avons défini, et seulement ensuite rendre le Markdown.