Skip to content

Commit 07bb8cf

Browse files
Merge pull request #18 from owlot/feature/audio-directive
feat: embed audio players from local sound files via ::: audio directive
2 parents ef70fb4 + 623a79a commit 07bb8cf

5 files changed

Lines changed: 158 additions & 0 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ in your favorite editor and keeps all your blogs in git.
55
## features
66
- converts markdown into plain html, with syntax hightlighting support
77
- uploads and synchronizes any locally referenced images
8+
- embeds audio players from locally referenced sound files
89
- generates an opengraph image including the title, subtitle and author in Binx.io or xebia.com style
910
- sets the Rankmath focus keywords
1011
- sets the canonical url, if specified
@@ -122,6 +123,21 @@ To add an image to your blog, add the images in the ./images subdirectory and ad
122123
![](./images/architecture.png)
123124
```
124125

126+
## adding audio
127+
To embed an audio player, add the sound file in a subdirectory (for example `./audio`)
128+
and add an `::: audio` directive on its own line, with a relative reference. For instance:
129+
130+
```markdown
131+
## Listen to this section
132+
133+
::: audio ./audio/section-one.mp3
134+
```
135+
136+
The sound file is uploaded and synchronized just like an image, and rendered as a native
137+
Wordpress audio block (a player with controls) at that position. Drop one directive under
138+
each heading to give every section its own player. Remote URLs (`https://...`) are embedded
139+
as-is without uploading.
140+
125141
## uploading a blog
126142
To upload a blog, type:
127143

src/wordpress_markdown_blog_loader/blog.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,14 @@ def __init__(self):
2929
self.path: Path = None
3030
self.blog: frontmatter.Post = frontmatter.Post(content="")
3131
self.uploaded_images: dict[str, Media] = {}
32+
self.uploaded_audio: dict[str, Medium] = {}
3233
self.markdown_image_pattern = re.compile(
3334
r'\!\[(?P<alt_text>[^]]*)\]\((?P<url>.*?)(?P<caption>\s*"[^"]*?")?\)'
3435
)
36+
# An audio player directive on its own line: `::: audio path/to/clip.mp3`
37+
self.audio_directive_pattern = re.compile(
38+
r"^[ \t]*:::[ \t]+audio[ \t]+(?P<url>\S+)[ \t]*$", re.MULTILINE
39+
)
3540

3641
@staticmethod
3742
def load(path: str) -> "Blog":
@@ -306,7 +311,19 @@ def replace_references(match: re.Match):
306311
return f"![{match.group('alt_text')}]({image.url}{caption})"
307312
return match.group(0)
308313

314+
def replace_audio_directive(match: re.Match):
315+
url = match.group("url")
316+
audio = self.uploaded_audio.get(url)
317+
src = audio.url if audio else url
318+
# raw block-level HTML; python-markdown passes it through untouched,
319+
# and html_to_gutenberg turns it into a wp:audio block.
320+
return (
321+
f'<figure class="wp-block-audio">'
322+
f'<audio controls src="{src}"></audio></figure>'
323+
)
324+
309325
content = self.markdown_image_pattern.sub(replace_references, self.content)
326+
content = self.audio_directive_pattern.sub(replace_audio_directive, content)
310327
html = markdown(
311328
content, extensions=["fenced_code", "attr_list", "tables", "footnotes"],
312329
)
@@ -395,9 +412,41 @@ def upload_local_images(self, wp: Wordpress):
395412
slug = self.slug + "-" + re.sub(r"[/\.\\]+", "-", path.stem.strip("-"))
396413
self.uploaded_images[filename] = wp.upload_media(slug, path)
397414

415+
@property
416+
def local_audio_references(self) -> set[str]:
417+
"""
418+
Local audio files referenced by `::: audio <path>` directives.
419+
420+
>>> b = Blog()
421+
>>> b.content = "## H\\n\\n::: audio images/clip.mp3\\n\\ntext\\n"
422+
>>> sorted(b.local_audio_references)
423+
['images/clip.mp3']
424+
"""
425+
return set(
426+
filter(
427+
lambda u: urlparse(u).scheme in ["", "file"],
428+
map(
429+
lambda m: m.group("url"),
430+
re.finditer(self.audio_directive_pattern, self.content),
431+
),
432+
)
433+
)
434+
435+
def upload_local_audio(self, wp: Wordpress):
436+
self.uploaded_audio = {}
437+
for filename in self.local_audio_references:
438+
path = Path(self.dir).joinpath(filename)
439+
if not path.exists():
440+
logging.warning("%s does not exist", path)
441+
continue
442+
443+
slug = self.slug + "-" + re.sub(r"[/\.\\]+", "-", path.stem.strip("-"))
444+
self.uploaded_audio[filename] = wp.upload_media(slug, path)
445+
398446
def to_wordpress(self, wp: Wordpress) -> dict:
399447
author = wp.get_unique_user_by_name(self.author, self.email, self.author_id)
400448
self.upload_local_images(wp)
449+
self.upload_local_audio(wp)
401450
result = {
402451
"title": self.title,
403452
"slug": self.slug,

src/wordpress_markdown_blog_loader/html_to_gutenberg.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ def _wrap_in_gutenberg_comments(element):
1414
return _wrap_list(element)
1515
if element.name == "img":
1616
return _wrap_image(element)
17+
if element.name == "figure" and "wp-block-audio" in element.get("class", []):
18+
return _wrap_audio(element)
19+
if element.name == "audio":
20+
return _wrap_audio(element)
1721
if element.name == "blockquote":
1822
return _wrap_quote(element)
1923
return str(element)
@@ -86,6 +90,24 @@ def _wrap_image(element):
8690
return f"<!-- wp:image -->\n{str(element)}\n<!-- /wp:image -->"
8791

8892

93+
def _wrap_audio(element):
94+
"""
95+
Wrap an audio element in a wp:audio Gutenberg block. Accepts either a bare
96+
<audio> tag or a <figure class="wp-block-audio"> already wrapping one, and
97+
always emits the figure form Wordpress expects.
98+
99+
>>> from bs4 import BeautifulSoup
100+
>>> soup = BeautifulSoup('<audio controls src="https://x/c.mp3"></audio>', "html.parser")
101+
>>> _wrap_audio(soup.audio)
102+
'<!-- wp:audio -->\\n<figure class="wp-block-audio"><audio controls="" src="https://x/c.mp3"></audio></figure>\\n<!-- /wp:audio -->'
103+
"""
104+
audio = element if element.name == "audio" else element.find("audio")
105+
figure = (
106+
f'<figure class="wp-block-audio">{str(audio)}</figure>' if audio else str(element)
107+
)
108+
return f"<!-- wp:audio -->\n{figure}\n<!-- /wp:audio -->"
109+
110+
89111
def convert(title, html_body):
90112
soup = BeautifulSoup(html_body, "html.parser")
91113
blocks = []

tests/test_blog.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,38 @@ def test_tables(self):
1515
self.assertIn('<tbody>', blog.rendered)
1616
self.assertIn('<td style="text-align: left;">Hello</td>', blog.rendered)
1717

18+
19+
class Test_AudioDirective(unittest.TestCase):
20+
def _blog(self, content):
21+
blog = Blog()
22+
blog.title = "Audio post"
23+
blog.content = content
24+
return blog
25+
26+
def test_local_audio_references(self):
27+
blog = self._blog(
28+
"## One\n\n::: audio images/clips/a.mp3\n\ntext\n\n"
29+
"## Two\n\n::: audio images/clips/b.wav\n\nmore\n"
30+
)
31+
self.assertEqual(
32+
blog.local_audio_references,
33+
{"images/clips/a.mp3", "images/clips/b.wav"},
34+
)
35+
36+
def test_remote_audio_not_collected_as_local(self):
37+
blog = self._blog("::: audio https://cdn.example.com/a.mp3\n")
38+
self.assertEqual(blog.local_audio_references, set())
39+
40+
def test_rendered_directive_becomes_audio_block(self):
41+
# without an uploaded URL it falls back to the raw path (local preview)
42+
blog = self._blog("## Section\n\n::: audio images/clips/a.mp3\n\ntext\n")
43+
rendered = blog.rendered
44+
self.assertIn("<!-- wp:audio -->", rendered)
45+
self.assertIn('<figure class="wp-block-audio">', rendered)
46+
self.assertIn('src="images/clips/a.mp3"', rendered)
47+
# the directive line must not leak through as a paragraph
48+
self.assertNotIn("::: audio", rendered)
49+
50+
1851
if __name__ == '__main__':
1952
unittest.main()

tests/test_html_to_gutenberg.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
_wrap_pre,
99
_wrap_list,
1010
_wrap_image,
11+
_wrap_audio,
1112
_wrap_in_gutenberg_comments,
1213
_wrap_quote,
1314
convert,
@@ -77,6 +78,31 @@ def test_wrap_image(self):
7778
self.assertIn('<img alt="desc" src="img.png"/>', res)
7879
self.assertTrue(res.endswith("<!-- /wp:image -->"))
7980

81+
def test_wrap_audio_from_bare_tag(self):
82+
soup = BeautifulSoup(
83+
'<audio controls src="https://x/c.mp3"></audio>', "html.parser"
84+
)
85+
res = _wrap_audio(soup.audio)
86+
self.assertTrue(res.startswith("<!-- wp:audio -->"))
87+
self.assertIn('<figure class="wp-block-audio">', res)
88+
self.assertIn('src="https://x/c.mp3"', res)
89+
self.assertIn("<audio", res)
90+
self.assertTrue(res.endswith("<!-- /wp:audio -->"))
91+
92+
def test_wrap_audio_from_figure(self):
93+
html = '<figure class="wp-block-audio"><audio controls src="https://x/c.mp3"></audio></figure>'
94+
soup = BeautifulSoup(html, "html.parser")
95+
res = _wrap_audio(soup.figure)
96+
self.assertTrue(res.startswith("<!-- wp:audio -->"))
97+
self.assertIn('<figure class="wp-block-audio">', res)
98+
self.assertIn('src="https://x/c.mp3"', res)
99+
100+
def test_wrap_in_gutenberg_comments_routes_audio_figure(self):
101+
html = '<figure class="wp-block-audio"><audio controls src="https://x/c.mp3"></audio></figure>'
102+
soup = BeautifulSoup(html, "html.parser")
103+
res = _wrap_in_gutenberg_comments(soup.figure)
104+
self.assertTrue(res.startswith("<!-- wp:audio -->"))
105+
80106
def test_wrap_in_gutenberg_comments_with_comment(self):
81107
comment = Comment(" a comment ")
82108
res = _wrap_in_gutenberg_comments(comment)
@@ -157,6 +183,18 @@ def test_convert_handles_multiple_elements(self):
157183
self.assertIn("<!-- wp:heading -->", result)
158184
self.assertIn("<!-- wp:code -->", result)
159185

186+
def test_convert_with_audio(self):
187+
title = "Audio"
188+
html_body = (
189+
'<h2>Section</h2>'
190+
'<figure class="wp-block-audio">'
191+
'<audio controls src="https://x/c.mp3"></audio></figure>'
192+
)
193+
result = convert(title, html_body)
194+
self.assertIn("<!-- wp:heading -->", result)
195+
self.assertIn("<!-- wp:audio -->", result)
196+
self.assertIn('<figure class="wp-block-audio">', result)
197+
160198

161199
if __name__ == "__main__":
162200
unittest.main()

0 commit comments

Comments
 (0)