My personal website
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

135 lines
5.3 KiB

#!/usr/bin/env python3
"""
Converts the custom shortcodes of www.itix.fr to standard Markdown syntax,
rendered by the render hooks in layouts/_markup.
{{< attachedFigure src="x.png" title="Caption" >}} -> ![Caption](x.png "Caption")
{{< screenshotOf src="tweet-x.png" href="URL" >}} -> [![Tweet by @x](tweet-x.png)](URL)
[text]({{< attachedFileLink src="x.pdf" >}}) -> [text](x.pdf)
(kept as-is outside of a Markdown link)
[text]({{< relref "/blog/x" >}}) -> [text](/blog/x)
{{< internalLink path="/blog/x.md" >}} -> [](/blog/x.md)
{{< internalLink path="/blog/x.md" title="T" >}} -> [T](/blog/x.md)
{{< highlightFile "f.yaml" "yaml" "hl_lines=3" >}} -> ```yaml {filename="f.yaml" hl_lines="3"}
{{< highlightWithTitle "T" "sh" "" >}} -> ```sh {title="T"}
{{< highlight yaml "hl_lines=5" >}} -> ```yaml {hl_lines="5"}
Usage: scripts/convert-shortcodes.py FILE.md [FILE.md...]
"""
import re
import sys
def parse_args(args):
"""Parses shortcode arguments: named (key="value") or positional."""
named, positional = {}, []
for m in re.finditer(r'(\w+)="((?:[^"\\]|\\.)*)"|"((?:[^"\\]|\\.)*)"|(\S+)', args):
if m.group(1):
named[m.group(1)] = m.group(2).replace('\\"', '"')
elif m.group(3) is not None:
positional.append(m.group(3).replace('\\"', '"'))
else:
positional.append(m.group(4))
return named, positional
def plain(text):
"""Strips the Markdown formatting of a caption, to be used as alt text."""
text = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', text)
text = re.sub(r'[`*_]', '', text)
return text.strip()
def md_title(text):
return '"%s"' % text.replace('\\', '\\\\').replace('"', '\\"')
def md_alt(text):
return text.replace('[', '\\[').replace(']', '\\]')
def fence_attrs(options):
"""Converts Hugo highlight options ("hl_lines=3 5-7,linenos=true") to fence attributes."""
attrs = []
for opt in re.split(r',\s*', options or ''):
if '=' not in opt:
continue
key, value = opt.split('=', 1)
if value.strip():
attrs.append('%s=%s' % (key.strip(), md_title(value.strip())))
return attrs
def fence(lang, attrs, inner):
inner = inner.strip('\n')
ticks = '```'
while ticks in inner:
ticks += '`'
info = lang + (' {%s}' % ' '.join(attrs) if attrs else '')
return '%s%s\n%s\n%s' % (ticks, info, inner, ticks)
def standalone(match, replacement):
"""Ensures a block-level element (figure) is separated by blank lines."""
s, start, end = match.string, match.start(), match.end()
before = '' if start == 0 or s[:start].endswith('\n\n') else ('\n' if s[:start].endswith('\n') else '\n\n')
after = '' if end == len(s) or s[end:].startswith('\n\n') else ('\n' if s[end:].startswith('\n') else '\n\n')
return before + replacement + after
def convert(text, lang):
# Code blocks
def code(m):
name, args, inner = m.group(1), m.group(2), m.group(3)
named, pos = parse_args(args)
if name == 'highlightFile':
return fence(pos[1], ['filename=%s' % md_title(pos[0])] + fence_attrs(pos[2] if len(pos) > 2 else ''), inner)
if name == 'highlightWithTitle':
return fence(pos[1], ['title=%s' % md_title(pos[0])] + fence_attrs(pos[2] if len(pos) > 2 else ''), inner)
return fence(pos[0], fence_attrs(pos[1] if len(pos) > 1 else ''), inner)
text = re.sub(r'\{\{<\s*(highlightFile|highlightWithTitle|highlight)\s+(.*?)\s*>\}\}(.*?)\{\{<\s*/\s*\1\s*>\}\}',
code, text, flags=re.S)
# Figures
def figure(m):
named, _ = parse_args(m.group(1))
title = named.get('title', '')
img = '![%s](%s%s)' % (md_alt(plain(title)), named['src'], ' ' + md_title(title) if title else '')
return standalone(m, img)
text = re.sub(r'\{\{<\s*attachedFigure\s+(.*?)\s*>\}\}', figure, text)
# Only one blank line between consecutive figures
text = re.sub(r'(!\[[^\n]*\))\n{3,}(?=!\[)', r'\1\n\n', text)
# Screenshots of tweets, linked to the tweet
def screenshot(m):
named, _ = parse_args(m.group(1))
handle = re.sub(r'^tweet-|(-\d+)?\.\w+$', '', named['src'])
alt = ('Tweet de @%s' if lang == 'fr' else 'Tweet by @%s') % handle
return '[![%s](%s)](%s)' % (alt, named['src'], named['href'])
text = re.sub(r'\{\{<\s*screenshotOf\s+(.*?)\s*>\}\}', screenshot, text)
# Links
# (attachedFileLink is kept when used elsewhere, e.g. in code blocks)
text = re.sub(r'\]\(\{\{<\s*attachedFileLink\s+src="([^"]+)"\s*>\}\}\)', r'](\1)', text)
text = re.sub(r'\{\{<\s*relref\s+"([^"]+)"\s*>\}\}', r'\1', text)
def internal_link(m):
named, _ = parse_args(m.group(1))
return '[%s](%s)' % (named.get('title', ''), named['path'])
text = re.sub(r'\{\{<\s*internalLink\s+(.*?)\s*>\}\}', internal_link, text)
return text
for filename in sys.argv[1:]:
with open(filename, encoding='utf-8') as f:
original = f.read()
converted = convert(original, 'fr' if '/french/' in filename else 'en')
if converted != original:
with open(filename, 'w', encoding='utf-8') as f:
f.write(converted)
print('converted:', filename)