Browse Source
The content now uses standard Markdown, rendered by render hooks
(layouts/_markup):
- images: , rendered as a figure when the
image is standalone (replaces attachedFigure and screenshotOf)
- links: resolved to a page resource (replaces attachedFileLink in
links), to a page of the current site or of the other site with a
language flag (replaces internalLink and relref), and relative links
are made relative to the site root (they were broken in RSS feeds)
- code blocks: ```yaml {filename="x.yaml" hl_lines="3"} or
{title="..."} (replaces highlightFile, highlightWithTitle, highlight)
The content was converted with scripts/convert-shortcodes.py, which can
also be used on the drafts. The attachedFileLink shortcode is kept for
the URLs in code blocks, as well as embeddedVideo and youtube.
The generated HTML is the same, except:
- figures are no longer nested in paragraphs (invalid HTML)
- the screenshots of tweets get an alt text
- the language flags are removed from the summaries
Closes #13
Refs #11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNHsWYTXPThURz6TbVS1j3
master
71 changed files with 673 additions and 396 deletions
@ -0,0 +1,24 @@ |
|||
{{- /* |
|||
-- Renders the fenced code blocks, optionally prefixed by a file name or a |
|||
-- title: |
|||
-- |
|||
-- ```yaml {filename="config.yaml" hl_lines="3-5"} |
|||
-- ```sh {title="CoreOS"} |
|||
*/ -}} |
|||
{{- $result := transform.HighlightCodeBlock . -}} |
|||
{{- $label := "" -}} |
|||
{{- with .Attributes.filename -}} |
|||
{{- $label = printf (i18n "file") . -}} |
|||
{{- else -}} |
|||
{{- with .Attributes.title -}}{{- $label = . -}}{{- end -}} |
|||
{{- end -}} |
|||
{{- /* The filename and title attributes must not be rendered on the wrapper */ -}} |
|||
{{- $highlighted := replaceRE `^<div class="highlight"[^>]*>` `<div class="highlight">` (string $result.Wrapped) 1 | safeHTML -}} |
|||
{{- if $label -}} |
|||
<div class="highlightedFile"> |
|||
<div class="filename">{{ $label }}</div> |
|||
{{- $highlighted -}} |
|||
</div> |
|||
{{- else -}} |
|||
{{- $highlighted -}} |
|||
{{- end -}} |
|||
@ -0,0 +1,32 @@ |
|||
{{- /* |
|||
-- Renders the Markdown images. |
|||
-- |
|||
-- The image is resolved to a resource of the current page, or to a global |
|||
-- resource (assets, static/images). A standalone image is rendered as a |
|||
-- figure, with its title as caption (Markdown allowed): |
|||
-- |
|||
--  |
|||
*/ -}} |
|||
{{- $u := urls.Parse .Destination -}} |
|||
{{- $src := .Destination -}} |
|||
{{- if not $u.IsAbs -}} |
|||
{{- with or (.Page.Resources.Get $u.Path) (resources.Get (strings.TrimPrefix "/" $u.Path)) -}} |
|||
{{- $src = .RelPermalink -}} |
|||
{{- else -}} |
|||
{{- if not (strings.HasPrefix $u.Path "/") -}} |
|||
{{- errorf "%s: image %q not found" .Page.String .Destination -}} |
|||
{{- end -}} |
|||
{{- end -}} |
|||
{{- end -}} |
|||
{{- if .IsBlock -}} |
|||
<figure> |
|||
<img src="{{ $src }}" alt="{{ .PlainText }}"> |
|||
{{- with .Title }} |
|||
<figcaption> |
|||
{{- . | markdownify -}} |
|||
</figcaption> |
|||
{{- end }} |
|||
</figure> |
|||
{{- else -}} |
|||
<img src="{{ $src }}" alt="{{ .PlainText }}"> |
|||
{{- end -}} |
|||
@ -0,0 +1,64 @@ |
|||
{{- /* |
|||
-- Renders the Markdown links. |
|||
-- |
|||
-- Internal links are resolved, in this order: |
|||
-- 1. to a resource of the current page (attached file), which is then |
|||
-- published: [the slides](slides.pdf) |
|||
-- 2. to a page of the current site, then of the other site (language). |
|||
-- A flag is displayed when the target page is in a different language |
|||
-- than the current page. When the link text is empty, the title of the |
|||
-- target page is used: [](/blog/my-article.md) |
|||
-- 3. relative links (../other-article/) are made relative to the site root, |
|||
-- so that they also work in the RSS feeds. |
|||
-- |
|||
-- The output must not contain any extra whitespace since the link is |
|||
-- usually embedded in a sentence. |
|||
*/ -}} |
|||
{{- $u := urls.Parse .Destination -}} |
|||
{{- $href := .Destination -}} |
|||
{{- /* |
|||
-- An image wrapped in a link ([](URL)) is rendered as a |
|||
-- figure by the image render hook: a figure is not allowed in a link, so |
|||
-- only the image is kept. |
|||
*/ -}} |
|||
{{- $text := replaceRE `^\s*<figure>\s*(<img[^>]*>)\s*</figure>\s*$` "$1" .Text -}} |
|||
{{- $flag := "" -}} |
|||
{{- /* Links whose destination is a shortcode are left as-is (placeholder) */ -}} |
|||
{{- if and (not $u.IsAbs) $u.Path (not (strings.Contains .Destination "HAHAHUGOSHORTCODE")) -}} |
|||
{{- $fragment := cond (ne $u.Fragment "") (printf "#%s" $u.Fragment) "" -}} |
|||
{{- $currentPage := .Page -}} |
|||
{{- $currentLang := .Page.Lang -}} |
|||
|
|||
{{- /* 1. Resources of the current page (attached files) */ -}} |
|||
{{- with .Page.Resources.Get $u.Path -}} |
|||
{{- $href = printf "%s%s" .RelPermalink $fragment -}} |
|||
{{- else -}} |
|||
{{- /* 2. Pages of the current site first, then of the other site */ -}} |
|||
{{- $target := $currentPage.GetPage $u.Path -}} |
|||
{{- if and (not $target) (strings.HasPrefix $u.Path "/") -}} |
|||
{{- range hugo.Sites -}} |
|||
{{- if and (not $target) (ne .Language.Lang $currentLang) -}} |
|||
{{- with .GetPage $u.Path -}}{{- $target = . -}}{{- end -}} |
|||
{{- end -}} |
|||
{{- end -}} |
|||
{{- end -}} |
|||
|
|||
{{- if $target -}} |
|||
{{- $href = printf "%s%s" $target.RelPermalink $fragment -}} |
|||
{{- if not $text -}}{{- $text = $target.Title | htmlEscape -}}{{- end -}} |
|||
{{- if and (eq $currentLang "en") (eq $target.Lang "fr") -}} |
|||
{{- $flag = partial "icons/fr.svg" $target -}} |
|||
{{- else if eq $currentLang "fr" -}} |
|||
{{- partial "lang-detection.html" $target -}} |
|||
{{- if $target.Store.Get "en_article_in_fr_site" -}} |
|||
{{- $flag = partial "icons/en.svg" $target -}} |
|||
{{- end -}} |
|||
{{- end -}} |
|||
{{- else if not (strings.HasPrefix $u.Path "/") -}} |
|||
{{- /* 3. Relative links */ -}} |
|||
{{- $href = printf "%s%s%s" (path.Join $currentPage.RelPermalink $u.Path) (cond (strings.HasSuffix $u.Path "/") "/" "") $fragment -}} |
|||
{{- end -}} |
|||
{{- end -}} |
|||
{{- end -}} |
|||
<a href="{{ $href | safeURL }}"{{ with .Title }} title="{{ . }}"{{ end }}>{{ $text | safeHTML }}</a> |
|||
{{- with $flag }} {{ . | strings.TrimSpace | safeHTML }}{{ end -}} |
|||
@ -0,0 +1,135 @@ |
|||
#!/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" >}} ->  |
|||
{{< screenshotOf src="tweet-x.png" href="URL" >}} -> [](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 = '' % (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)' % (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) |
|||
Loading…
Reference in new issue