wordpress-markdown-blog-loader 1.6.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wordpress_markdown_blog_loader/__init__.py +0 -0
- wordpress_markdown_blog_loader/__main__.py +32 -0
- wordpress_markdown_blog_loader/api.py +658 -0
- wordpress_markdown_blog_loader/blog.py +621 -0
- wordpress_markdown_blog_loader/check_links.py +77 -0
- wordpress_markdown_blog_loader/download.py +67 -0
- wordpress_markdown_blog_loader/name_to_email.py +22 -0
- wordpress_markdown_blog_loader/new.py +158 -0
- wordpress_markdown_blog_loader/remove_newlines.py +22 -0
- wordpress_markdown_blog_loader/upload.py +112 -0
- wordpress_markdown_blog_loader-1.6.3.dist-info/METADATA +219 -0
- wordpress_markdown_blog_loader-1.6.3.dist-info/RECORD +16 -0
- wordpress_markdown_blog_loader-1.6.3.dist-info/WHEEL +5 -0
- wordpress_markdown_blog_loader-1.6.3.dist-info/entry_points.txt +2 -0
- wordpress_markdown_blog_loader-1.6.3.dist-info/licenses/LICENSE +202 -0
- wordpress_markdown_blog_loader-1.6.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from wordpress_markdown_blog_loader.api import Wordpress, Post
|
|
8
|
+
from wordpress_markdown_blog_loader.blog import Blog
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command(name="download")
|
|
12
|
+
@click.option(
|
|
13
|
+
"--host", type=str, required=True, nargs=1, help="wordpress host to upload to"
|
|
14
|
+
)
|
|
15
|
+
@click.option(
|
|
16
|
+
"--directory",
|
|
17
|
+
type=click.Path(file_okay=False, exists=True),
|
|
18
|
+
required=True,
|
|
19
|
+
nargs=1,
|
|
20
|
+
help="to download to",
|
|
21
|
+
)
|
|
22
|
+
@click.argument(
|
|
23
|
+
"post-id",
|
|
24
|
+
type=int,
|
|
25
|
+
nargs=-1,
|
|
26
|
+
)
|
|
27
|
+
def command(host: str, directory: str, post_id: tuple[str]):
|
|
28
|
+
"""
|
|
29
|
+
WordPress posts as markdown.
|
|
30
|
+
|
|
31
|
+
Reads all the posts from a Wordpress installation and writes each post as frontmatter
|
|
32
|
+
document. If posts id's are specified, the selected posts are downloaded.
|
|
33
|
+
"""
|
|
34
|
+
wordpress = Wordpress(host)
|
|
35
|
+
wordpress.connect()
|
|
36
|
+
|
|
37
|
+
if post_id:
|
|
38
|
+
posts = list(
|
|
39
|
+
map(
|
|
40
|
+
lambda p: Post(p) if p else None,
|
|
41
|
+
map(
|
|
42
|
+
lambda p: wordpress.get("posts", p, ({"context": "edit"})), post_id
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
for i, post in enumerate(posts):
|
|
48
|
+
if not post:
|
|
49
|
+
logging.error("post with id %d was not found", post_id[i])
|
|
50
|
+
exit(1)
|
|
51
|
+
else:
|
|
52
|
+
posts = wordpress.posts({"context": "edit"})
|
|
53
|
+
|
|
54
|
+
for post in posts:
|
|
55
|
+
blog = Blog.from_wordpress(post, directory, wordpress)
|
|
56
|
+
blog.download_remote_images(wordpress, f"{blog.slug}-" if blog.slug else "")
|
|
57
|
+
logging.info("writing %s", blog.path)
|
|
58
|
+
blog.remove_empty_lines()
|
|
59
|
+
blog.save()
|
|
60
|
+
|
|
61
|
+
with open(f"{blog.dir}/index.html", "w") as file:
|
|
62
|
+
file.write(post.content)
|
|
63
|
+
|
|
64
|
+
if "</span>" in blog.content:
|
|
65
|
+
os.replace(blog.path, Path(blog.dir).joinpath("index.prespan.md"))
|
|
66
|
+
blog.remove_span_tags()
|
|
67
|
+
blog.save()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from text_unidecode import unidecode
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def name_to_email(name: str) -> str:
|
|
5
|
+
"""
|
|
6
|
+
name to email Xebia style.
|
|
7
|
+
|
|
8
|
+
>>> name_to_email("Léon Rodenburg")
|
|
9
|
+
'leon.rodenburg@xebia.com'
|
|
10
|
+
>>> name_to_email("Mark van Holsteijn")
|
|
11
|
+
'mark.vanholsteijn@xebia.com'
|
|
12
|
+
>>> name_to_email("Jorge Liauw-a-joe")
|
|
13
|
+
'jorge.liauwajoe@xebia.com'
|
|
14
|
+
>>> name_to_email("Jan-Justin van Tonder")
|
|
15
|
+
'janjustin.vantonder@xebia.com'
|
|
16
|
+
"""
|
|
17
|
+
email_name_exceptions = {}
|
|
18
|
+
parts = name.replace("-", "").split()
|
|
19
|
+
email = unidecode(
|
|
20
|
+
f'{parts[0]}{"." if len(parts) > 1 else ""}{"".join(parts[1:])}@xebia.com'.lower()
|
|
21
|
+
)
|
|
22
|
+
return email_name_exceptions.get(email, email)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import click
|
|
3
|
+
from urllib.request import urlopen
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from slugify import slugify
|
|
7
|
+
from PIL import Image
|
|
8
|
+
from wordpress_markdown_blog_loader.blog import Blog
|
|
9
|
+
from wordpress_markdown_blog_loader.name_to_email import name_to_email
|
|
10
|
+
import stopwords
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
import logging
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ImageType(click.ParamType):
|
|
19
|
+
name = "image-url"
|
|
20
|
+
|
|
21
|
+
def convert(self, value, param, ctx):
|
|
22
|
+
try:
|
|
23
|
+
url = urlparse(value)
|
|
24
|
+
if url.netloc == "":
|
|
25
|
+
url = urlparse(Path(value).absolute().as_uri())
|
|
26
|
+
response = urlopen(url.geturl())
|
|
27
|
+
content_type = response.headers.get("Content-Type")
|
|
28
|
+
if not content_type.startswith("image/"):
|
|
29
|
+
self.fail(f"{value} is not an image type, but a {content_type}")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
bytes = response.read()
|
|
33
|
+
return Image.open(BytesIO(bytes))
|
|
34
|
+
except ValueError as error:
|
|
35
|
+
self.fail("%s, cannot read image url" % value, param, ctx)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.command(name="new")
|
|
39
|
+
@click.option("--title", required=True, help="of the blog")
|
|
40
|
+
@click.option("--subtitle", required=False, help="(Optional) subtitle of the blog")
|
|
41
|
+
@click.option("--author", required=True, help="of the blog")
|
|
42
|
+
@click.option("--email", required=False, help="of the author")
|
|
43
|
+
@click.option("--image", required=False, type=ImageType(), help="for the banner")
|
|
44
|
+
@click.option("--image-credits", required=False, type=str, help="for the used image")
|
|
45
|
+
@click.option(
|
|
46
|
+
"--brand",
|
|
47
|
+
type=click.Choice(["xebia.com", "binx.io"]),
|
|
48
|
+
required=True,
|
|
49
|
+
default="xebia.com",
|
|
50
|
+
help="of the banner",
|
|
51
|
+
)
|
|
52
|
+
def command(title, subtitle, author, image, brand, email, image_credits):
|
|
53
|
+
"""
|
|
54
|
+
a new frontmatter blog
|
|
55
|
+
|
|
56
|
+
the index.md will be written in a subdirectory with the name of the slug for the blog.
|
|
57
|
+
The slug is created from the title. The image will be resized and cropped to 1200x630.
|
|
58
|
+
"""
|
|
59
|
+
slug = slugify(title)
|
|
60
|
+
|
|
61
|
+
directory = Path(slug)
|
|
62
|
+
if directory.exists():
|
|
63
|
+
raise click.UsageError(f'a directory with the name "{slug}" already exists')
|
|
64
|
+
|
|
65
|
+
Path(slug).mkdir()
|
|
66
|
+
blog = Blog()
|
|
67
|
+
blog.dir = directory
|
|
68
|
+
blog.path = directory.joinpath("index.md")
|
|
69
|
+
blog.title = title.strip()
|
|
70
|
+
if subtitle:
|
|
71
|
+
blog.subtitle = subtitle.strip()
|
|
72
|
+
blog.focus_keywords = " ".join(stopwords.clean(title.lower().split(), "en"))
|
|
73
|
+
blog.status = "draft"
|
|
74
|
+
blog.slug = slug
|
|
75
|
+
blog.author = author
|
|
76
|
+
blog.brand = brand
|
|
77
|
+
blog.email = email if email else name_to_email(author)
|
|
78
|
+
blog.date = (datetime.now().astimezone() + timedelta(days=7)).replace(
|
|
79
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not blog.title:
|
|
83
|
+
click.echo("a title is required", err=True)
|
|
84
|
+
exit(1)
|
|
85
|
+
|
|
86
|
+
if image:
|
|
87
|
+
image_path = save_og_image(image, directory.joinpath("images/banner"))
|
|
88
|
+
blog.image = image_path.relative_to(directory).as_posix()
|
|
89
|
+
|
|
90
|
+
blog.og_description = (
|
|
91
|
+
blog.title.lstrip().lower()[0]
|
|
92
|
+
+ blog.title[1:]
|
|
93
|
+
+ (", " + blog.subtitle if blog.subtitle else "")
|
|
94
|
+
)
|
|
95
|
+
if image_credits:
|
|
96
|
+
blog.content = f"\n\n___\n{image_credits}"
|
|
97
|
+
blog.save()
|
|
98
|
+
logging.info("start editing index.md in %s", blog.dir)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def save_og_image(image: Image, path: Path) -> Path:
|
|
102
|
+
"""
|
|
103
|
+
save the image to be the perfect og image size: 1200x630px
|
|
104
|
+
"""
|
|
105
|
+
width, height = image.size
|
|
106
|
+
if width != 1200:
|
|
107
|
+
new_height = int(height * 1200 / width)
|
|
108
|
+
logging.info("resizing %dx%d to %dx%d", width, height, 1200, new_height)
|
|
109
|
+
image = image.resize((1200, new_height))
|
|
110
|
+
width = 1200
|
|
111
|
+
width, height = image.size
|
|
112
|
+
|
|
113
|
+
if height > 630:
|
|
114
|
+
logging.info("cropping to maximum height of 630px")
|
|
115
|
+
top = int((height - 630) / 2)
|
|
116
|
+
bottom = 630 + top
|
|
117
|
+
image = image.crop((0, top, 1200, bottom))
|
|
118
|
+
width, height = image.size
|
|
119
|
+
|
|
120
|
+
if height < 630:
|
|
121
|
+
new_image = Image.new("RGBA", (1200, 630), (255, 0, 0, 0))
|
|
122
|
+
new_image.paste(image, (0, int((630 - height) / 2)))
|
|
123
|
+
image = new_image
|
|
124
|
+
|
|
125
|
+
path.parent.mkdir(exist_ok=True, parents=True)
|
|
126
|
+
|
|
127
|
+
path = path.with_suffix(".png" if image.mode == "RGBA" else ".jpg")
|
|
128
|
+
with open(path, "wb") as file:
|
|
129
|
+
image.save(file, **image.info)
|
|
130
|
+
return path
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@click.command(name="update-banner")
|
|
134
|
+
@click.argument(
|
|
135
|
+
"blog", type=click.Path(exists=True, file_okay=False, readable=True), required=True
|
|
136
|
+
)
|
|
137
|
+
@click.argument("image", type=ImageType(), required=True)
|
|
138
|
+
@click.option(
|
|
139
|
+
"--brand",
|
|
140
|
+
type=click.Choice(["xebia.com", "binx.io"]),
|
|
141
|
+
help="of the banner",
|
|
142
|
+
)
|
|
143
|
+
def update_banner_command(blog, image, brand):
|
|
144
|
+
"""
|
|
145
|
+
of the blog.
|
|
146
|
+
|
|
147
|
+
reads the image, crops it to the 1200x630 dimensions and updates the blog's banner property.
|
|
148
|
+
Regenerates and updates the og-banner too.
|
|
149
|
+
"""
|
|
150
|
+
path = Path(blog).joinpath("index.md")
|
|
151
|
+
if not path.exists():
|
|
152
|
+
raise click.UsageError(f"{path} not found")
|
|
153
|
+
|
|
154
|
+
blog = Blog.load(path.as_posix())
|
|
155
|
+
image_path = save_og_image(image, blog.dir.joinpath("images/banner"))
|
|
156
|
+
blog.image = image_path.relative_to(blog.dir).as_posix()
|
|
157
|
+
blog.generate_og_image()
|
|
158
|
+
blog.save()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from bs4 import BeautifulSoup
|
|
2
|
+
from bs4.element import NavigableString
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def remove_newlines_from_paragraphs(html_document: str) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Removes all newlines from text inside paragraphs of the `html_document`.
|
|
8
|
+
|
|
9
|
+
Although the HTML specification says that newlines do not constitute line breaks,
|
|
10
|
+
WP interprets them as such :-(
|
|
11
|
+
|
|
12
|
+
https://www.w3.org/TR/REC-html40-971218/struct/text.html#whitespace
|
|
13
|
+
|
|
14
|
+
>>> remove_newlines_from_paragraphs('<html><body><p>This is a paragraph\\n with some \\n<b>bold</b> and\\n <i>italic</i> text. And\\n it has some\\n <a href="/">links</a> too.\\n</p></body></html>')
|
|
15
|
+
'<html><body><p>This is a paragraph with some <b>bold</b> and <i>italic</i> text. And it has some <a href="/">links</a> too. </p></body></html>'
|
|
16
|
+
"""
|
|
17
|
+
doc = BeautifulSoup(html_document, "html.parser")
|
|
18
|
+
for paragraph in doc.find_all("p", recursive=True):
|
|
19
|
+
for child in paragraph.children:
|
|
20
|
+
if isinstance(child, NavigableString):
|
|
21
|
+
child.replace_with(child.replace("\n", " "))
|
|
22
|
+
return str(doc)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from difflib import diff_bytes, unified_diff
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from wordpress_markdown_blog_loader.api import Wordpress, Post
|
|
8
|
+
from wordpress_markdown_blog_loader.blog import Blog
|
|
9
|
+
from wordpress_markdown_blog_loader.check_links import check_links
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def upsert_post(wp: Wordpress, blog: Blog) -> int:
|
|
14
|
+
|
|
15
|
+
if blog.guid:
|
|
16
|
+
if not wp.is_host_for(blog.guid):
|
|
17
|
+
raise ValueError(f"blog {blog.guid} is not stored on {wp.endpoint.host}")
|
|
18
|
+
|
|
19
|
+
post = Post(wp.get_resource_by_url(blog.guid, {"context": "edit"}))
|
|
20
|
+
if not post:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"blogs has a guid %s which is not available at %s", blog.guid, wp.host
|
|
23
|
+
)
|
|
24
|
+
wp_post = blog.to_wordpress(wp)
|
|
25
|
+
logging.info("updating blog '%s' %s", blog.title, post.link)
|
|
26
|
+
post = wp.update_post(blog.guid, wp_post)
|
|
27
|
+
else:
|
|
28
|
+
existing_post = wp.get_post_by_slug(blog.slug)
|
|
29
|
+
if existing_post:
|
|
30
|
+
logging.error(
|
|
31
|
+
"a post with the same slug already exists, %s", existing_post.guid
|
|
32
|
+
)
|
|
33
|
+
return 1
|
|
34
|
+
|
|
35
|
+
post = wp.create_post(blog.to_wordpress(wp))
|
|
36
|
+
blog.guid = post.guid
|
|
37
|
+
blog.save()
|
|
38
|
+
logging.info("uploaded blog '%s' as post %s", blog.title, post.link)
|
|
39
|
+
|
|
40
|
+
if blog.og_image:
|
|
41
|
+
og_image = wp.upload_media(f"{blog.slug}-og-banner", blog.og_image_path)
|
|
42
|
+
post_og_images = set()
|
|
43
|
+
for name in ["rank_math_twitter_image","rank_math_facebook_image"]:
|
|
44
|
+
post_og_images.add(post.get("meta", {}).get(name))
|
|
45
|
+
if og_image.url not in post_og_images:
|
|
46
|
+
logging.info("updating opengraph image to %s", og_image.url)
|
|
47
|
+
updated_post = wp.update_post(
|
|
48
|
+
blog.guid,
|
|
49
|
+
{
|
|
50
|
+
"meta": {
|
|
51
|
+
"rank_math_facebook_image": og_image.url,
|
|
52
|
+
"rank_math_twitter_image": og_image.url,
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if blog.image:
|
|
58
|
+
banner = wp.upload_media(f"{blog.slug}-banner", blog.image_path)
|
|
59
|
+
if post.featured_media != banner.medium_id:
|
|
60
|
+
wp.update_post(blog.guid, {"featured_media": banner.medium_id})
|
|
61
|
+
|
|
62
|
+
broken = check_links(post.content)
|
|
63
|
+
for link in broken:
|
|
64
|
+
logging.warning("broken link in post: %s", broken)
|
|
65
|
+
logging.info("post available at %s", post.link)
|
|
66
|
+
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@click.command(name="upload")
|
|
71
|
+
@click.option(
|
|
72
|
+
"--host", type=str, required=False, nargs=1, help="wordpress host to upload to"
|
|
73
|
+
)
|
|
74
|
+
@click.option(
|
|
75
|
+
"--regenerate-og-image",
|
|
76
|
+
is_flag=True,
|
|
77
|
+
default=False,
|
|
78
|
+
help="regenerates the og image for the targeted host",
|
|
79
|
+
)
|
|
80
|
+
@click.argument(
|
|
81
|
+
"blog", type=click.Path(exists=True, file_okay=False, readable=True), required=True
|
|
82
|
+
)
|
|
83
|
+
def command(host: str, blog: str, regenerate_og_image: bool):
|
|
84
|
+
"""
|
|
85
|
+
the blog to Wordpress
|
|
86
|
+
|
|
87
|
+
Reads the frontmatter describing the blog from the file index.md in the `blog` directory.
|
|
88
|
+
"""
|
|
89
|
+
blog = Blog.load(os.path.join(blog, "index.md"))
|
|
90
|
+
if not blog.slug:
|
|
91
|
+
logging.error("slug is required for the blog in %s", blog)
|
|
92
|
+
exit(1)
|
|
93
|
+
|
|
94
|
+
if blog.image and (regenerate_og_image or not blog.og_image):
|
|
95
|
+
logging.info("generating og:image based on %s", blog.image)
|
|
96
|
+
if not blog.brand:
|
|
97
|
+
logging.info("blog brand set to %s", host)
|
|
98
|
+
elif blog.brand != host:
|
|
99
|
+
logging.warning("change brand from %s to %s", blog.brand, host)
|
|
100
|
+
|
|
101
|
+
blog.brand = host
|
|
102
|
+
blog.generate_og_image()
|
|
103
|
+
blog.save()
|
|
104
|
+
|
|
105
|
+
wordpress = Wordpress(host)
|
|
106
|
+
wordpress.connect()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
upsert_post(wordpress, blog)
|
|
110
|
+
except ValueError as exception:
|
|
111
|
+
logging.error(exception)
|
|
112
|
+
sys.exit(1)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wordpress-markdown-blog-loader
|
|
3
|
+
Version: 1.6.3
|
|
4
|
+
Summary: load markdown blogs into wordpress
|
|
5
|
+
Author-email: Mark van Holsteijn <markvanholsteijn@binx.io>
|
|
6
|
+
License: APL2
|
|
7
|
+
Project-URL: Homepage, https://github.com/binxio/wordpress-markdown-blog-loader
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: Unix
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Requires-Dist: markdown
|
|
22
|
+
Requires-Dist: python-frontmatter
|
|
23
|
+
Requires-Dist: click
|
|
24
|
+
Requires-Dist: pytz
|
|
25
|
+
Requires-Dist: Pillow
|
|
26
|
+
Requires-Dist: binx-og-image-generator>=1.2.6
|
|
27
|
+
Requires-Dist: markdownify
|
|
28
|
+
Requires-Dist: bs4
|
|
29
|
+
Requires-Dist: html5lib
|
|
30
|
+
Requires-Dist: lxml
|
|
31
|
+
Requires-Dist: python-slugify
|
|
32
|
+
Requires-Dist: unidecode
|
|
33
|
+
Requires-Dist: stopwords
|
|
34
|
+
Provides-Extra: testing
|
|
35
|
+
Requires-Dist: requests; extra == "testing"
|
|
36
|
+
Requires-Dist: markdown; extra == "testing"
|
|
37
|
+
Requires-Dist: python-frontmatter; extra == "testing"
|
|
38
|
+
Requires-Dist: click; extra == "testing"
|
|
39
|
+
Requires-Dist: pytz; extra == "testing"
|
|
40
|
+
Requires-Dist: Pillow; extra == "testing"
|
|
41
|
+
Requires-Dist: binx-og-image-generator>=1.2.6; extra == "testing"
|
|
42
|
+
Requires-Dist: markdownify; extra == "testing"
|
|
43
|
+
Requires-Dist: bs4; extra == "testing"
|
|
44
|
+
Requires-Dist: html5lib; extra == "testing"
|
|
45
|
+
Requires-Dist: lxml; extra == "testing"
|
|
46
|
+
Requires-Dist: python-slugify; extra == "testing"
|
|
47
|
+
Requires-Dist: pytest; extra == "testing"
|
|
48
|
+
Requires-Dist: unidecode; extra == "testing"
|
|
49
|
+
Requires-Dist: stopwords; extra == "testing"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# wordpress-markdown-blog-loader
|
|
53
|
+
This utility loads markdown blogs into Wordpress as a post. It allows you to work on your blog
|
|
54
|
+
in your favorite editor and keeps all your blogs in git.
|
|
55
|
+
|
|
56
|
+
## features
|
|
57
|
+
- converts markdown into plain html, with syntax hightlighting support
|
|
58
|
+
- uploads and synchronizes any locally referenced images
|
|
59
|
+
- generates an opengraph image including the title, subtitle and author in Binx.io or xebia.com style
|
|
60
|
+
- sets the Rankmath focus keywords
|
|
61
|
+
- sets the canonical url, if specified
|
|
62
|
+
|
|
63
|
+
## caveats
|
|
64
|
+
- changing the slug may orphan images
|
|
65
|
+
- removing images from the markdown, will leave dangling images in Wordpress
|
|
66
|
+
- you cannot edit via WP and via the uploader, without confusing yourself
|
|
67
|
+
|
|
68
|
+
## required Wordpress Plugins
|
|
69
|
+
|
|
70
|
+
- [Rankmath](https://rankmath.com/wordpress/plugin/seo-suite)
|
|
71
|
+
- [REST API Meta Support](https://wordpress.org/plugins/rest-api-meta-support/)
|
|
72
|
+
|
|
73
|
+
Furthermore, you have to enable the Rest API for the [Custom Field Group](https://www.advancedcustomfields.com/resources/wp-rest-api-integration/#enabling-the-rest-api-for-your-acf-fields) for the field `show_header_image`.
|
|
74
|
+
|
|
75
|
+
## Installation
|
|
76
|
+
Install it with [pipx](https://pypa.github.io/pipx/) or use Docker (see below):
|
|
77
|
+
```shell
|
|
78
|
+
pipx install wordpress-markdown-blog-loader
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## configuration
|
|
82
|
+
to configure the access credentials, you need to add your WordPress application password to the file `~/.wordpress.ini`
|
|
83
|
+
and add a section for your Wordpress installation:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
[DEFAULT]
|
|
87
|
+
host = xebia.com
|
|
88
|
+
|
|
89
|
+
[xebia.com]
|
|
90
|
+
api_host = xebiainnovationproject.kinsta.cloud
|
|
91
|
+
username = <your wordpress username>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## api host
|
|
95
|
+
If the site is served through a CDN, you can also set the `api_host` which will be used as the hostname to invoke the WP REST API.
|
|
96
|
+
|
|
97
|
+
## password
|
|
98
|
+
To authenticate you need an [application password](https://wordpress.com/support/security/two-step-authentication/application-specific-passwords/), which is different from the user password.
|
|
99
|
+
|
|
100
|
+
We recommend to store your application password in 1password and set the environment variable
|
|
101
|
+
WP_APP_PASSWORD using the [1password CLI](https://developer.1password.com/docs/cli):
|
|
102
|
+
|
|
103
|
+
```shell
|
|
104
|
+
WP_APP_PASSWORD="$(op read "op://Private/wordpress app password/password")" wp-md ...
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Never touch the WP editor again
|
|
108
|
+
|
|
109
|
+
Once you start to manage your blogs via this uploader, *do not edit* the blog via one of the WP editors. The editors are weird,
|
|
110
|
+
because it appears to make a copy of the content on which you get a WYSIWIG viewer. Unfortunately, it does not detect changes
|
|
111
|
+
in the actual blog content. It will look like your uploaded changes are not applied (but they are).
|
|
112
|
+
|
|
113
|
+
## Using the docker image
|
|
114
|
+
To use the docker image as a command line utility, create the following alias:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
alias wp-md='docker run \
|
|
118
|
+
-e WP_APP_PASSWORD="$(op read "op://Private/wordpress app password/password")" \
|
|
119
|
+
-v $HOME:$HOME \
|
|
120
|
+
-v $HOME/.wordpress.ini:/root/.wordpress.ini \
|
|
121
|
+
-v $PWD:/$PWD \
|
|
122
|
+
-w $PWD ghcr.io/binxio/wordpress-markdown-blog-loader:1.6.3
|
|
123
|
+
```'
|
|
124
|
+
Assuming that your WordPress app password is stored in the 1password Private vault under the name `wordpress app password`
|
|
125
|
+
|
|
126
|
+
## start a new blog
|
|
127
|
+
To start a new blog, type:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
$ wp-md posts new \
|
|
131
|
+
--title "How to create a WordPress blog without touching WordPress" \
|
|
132
|
+
--subtitle "using the wp-md utility" \
|
|
133
|
+
--author "Mark van Holsteijn" \
|
|
134
|
+
--image ~/Downloads/background-image.jpg \
|
|
135
|
+
--image-credits "image by xyz"
|
|
136
|
+
INFO: resizing 1920x1920 to 1200x1200
|
|
137
|
+
INFO: cropping to maximum height of 630px
|
|
138
|
+
INFO: start editing index.md in ./how-to-create-a-wordpress-blog-without-touching-wordpress
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
A skaffold frontmatter blog is created, and you can start writing in the index.md.
|
|
142
|
+
|
|
143
|
+
## frontmatter properties
|
|
144
|
+
You can set the following properties in the frontmatter:
|
|
145
|
+
|
|
146
|
+
| Name | description |
|
|
147
|
+
|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
148
|
+
| title | of the blog |
|
|
149
|
+
| subtitle | of the blog, used in the og.image |
|
|
150
|
+
| focus-keywords | the SEO focus keywords
|
|
151
|
+
| excerpt | excerpt of the blog |
|
|
152
|
+
| author | display name of the author in Wordpress |
|
|
153
|
+
| email | email address of the author, used to lookup profile picture on gravatar.com |
|
|
154
|
+
| author-id | Wordpress author slug: used to select the appropriate user if multiple users with the same name exists in WP and we cannot read the email address |
|
|
155
|
+
| categories | list of wordpress categories for this blog |
|
|
156
|
+
| slug | slug of the blog |
|
|
157
|
+
| date | on which the blog should be published ISO timestamp format |
|
|
158
|
+
| status | draft or publish. if publish, the blog will be published on the `date` |
|
|
159
|
+
| canonical | url of the blog, to be used in cross posts |
|
|
160
|
+
| image | the banner image of the blog |
|
|
161
|
+
| og.image | the open graph image of the blog, used in links from social media |
|
|
162
|
+
| og.description | the open graph description of the blog, used in links from social media |
|
|
163
|
+
| guid | the physical URL of the blog. Written by wp-md on upload and download |
|
|
164
|
+
| brand | xebia.com or xebia.com. defaults to xebia.com |
|
|
165
|
+
| industries | zero or more of banking-and-financial-services, energy-utilities, healthcare-life-sciences, insurance, isv-tech, non-profit, private-equity, public-sector, retail-and-consumer-goods, telecom-media |
|
|
166
|
+
| partners | zero or more of modernization-experience-based-acceleration-modax, genai-on-google-cloud, monday, cloud-workplace-solutions, cloud-workplace, google-workplace-tools, workplace-security, workplace-optimization, microsoft-library, developer-productivity-with-github-copilot |
|
|
167
|
+
| capabilities | zero or more of agile-transformation, applied-and-genai, cloud, data-analytics, devops-sre, digital-product-management, intelligent-automation, it-strategy, platform-engineering, product-platform-development |
|
|
168
|
+
|
|
169
|
+
## adding images
|
|
170
|
+
To add an image to your blog, add the images in the ./images subdirectory and add a relative reference in markdown. For instance:
|
|
171
|
+
|
|
172
|
+
```markdown
|
|
173
|
+

|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## uploading a blog
|
|
177
|
+
To upload a blog, type:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
$ wp-md posts upload --host xebia.com .
|
|
181
|
+
INFO: generating og:image based on images/banner.jpg
|
|
182
|
+
INFO: generating new image in how-to-create-a-wordpress-blog-without-touching-wordpress/images/og-banner.jpg
|
|
183
|
+
INFO: add logo
|
|
184
|
+
INFO: add title
|
|
185
|
+
INFO: add subtitle
|
|
186
|
+
INFO: add author
|
|
187
|
+
INFO: og image saved to how-to-create-a-wordpress-blog-without-touching-wordpress/images/og-banner.jpg
|
|
188
|
+
INFO: uploaded blog 'How to create a WordPress blog without touching WordPress' as post https://xebia.com/?p=9625
|
|
189
|
+
INFO: updating opengraph image to https://xebia.com/wp-content/uploads/2023/01/how-to-create-a-wordpress-blog-without-touching-wordpress-og-banner.jpg
|
|
190
|
+
INFO: post available at https://xebia.com/?p=9625
|
|
191
|
+
INFO: uploading image as how-to-create-a-wordpress-blog-without-touching-wordpress-og-banner.jpg
|
|
192
|
+
INFO: uploading image as how-to-create-a-wordpress-blog-without-touching-wordpress-banner.jpg
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## updating / publishing a blog
|
|
196
|
+
You can update the blog, by uploading it again. If you change the status to 'publish' in the frontmatter metadata,
|
|
197
|
+
the blog will be published on the specified date.
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
$ wp-md posts upload --host xebia.com .
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## updating banner and open graph images
|
|
204
|
+
You can update the banner and open graph images as follows:
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
$ wp-md posts update-banner . new-banner.jpg
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## downloading an existing blog
|
|
211
|
+
to download an existing blog and convert it to markdown, type:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
$ wp-md posts download --host xebia.com --directory /tmp 9625
|
|
215
|
+
INFO: downloading https://xebia.com/wp-content/uploads/2023/01/how-to-create-a-wordpress-blog-without-touching-wordpress-banner.jpg as banner.jpg
|
|
216
|
+
INFO: downloading https://xebia.com/wp-content/uploads/2023/01/how-to-create-a-wordpress-blog-without-touching-wordpress-og-banner.jpg as og-banner.jpg
|
|
217
|
+
INFO: writing /tmp/2023/01/how-to-create-a-wordpress-blog-without-touching-wordpress/index.md
|
|
218
|
+
```
|
|
219
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
wordpress_markdown_blog_loader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
wordpress_markdown_blog_loader/__main__.py,sha256=YhZsfRj-HpQBIrldz1HckHT5HBJ57zW_Xnl1qchOvXM,601
|
|
3
|
+
wordpress_markdown_blog_loader/api.py,sha256=fYhvLXGudtaaVGTEz8NI4jMhOclmZULWKTQ2Vu01TbA,20300
|
|
4
|
+
wordpress_markdown_blog_loader/blog.py,sha256=Okx0r6Vw7clIf3XH6XfD9xCv57gS6W5JqRUENl0qpYU,20281
|
|
5
|
+
wordpress_markdown_blog_loader/check_links.py,sha256=1brKobcTrAlXZWoz2Bs-6Nb6BpDDFOX5ruzCP_CkFpQ,2071
|
|
6
|
+
wordpress_markdown_blog_loader/download.py,sha256=MUY79Om7c19yPBRZsx1reiSppDC-nWBeArqn4NOsGEs,1891
|
|
7
|
+
wordpress_markdown_blog_loader/name_to_email.py,sha256=v_ok--6qTpqMJOVIKu1HQ6vb0RDvt_nP1tpkJiIb6WY,676
|
|
8
|
+
wordpress_markdown_blog_loader/new.py,sha256=fNj9Q-ytrVhg1EicbqdKv9IMwPn5fPlJ49n7Gsvd8p0,5245
|
|
9
|
+
wordpress_markdown_blog_loader/remove_newlines.py,sha256=bLYnQkgmF_7mrMtvb9gKeLECOPibyRrU57_vHgAJvVM,1065
|
|
10
|
+
wordpress_markdown_blog_loader/upload.py,sha256=MmVHsB0IEX88g7zdAA_Ij_K_IP6s0vrGh7eM2_YpPLU,3730
|
|
11
|
+
wordpress_markdown_blog_loader-1.6.3.dist-info/licenses/LICENSE,sha256=CCdvp2yieLoTYR0ayYI6CED2Qla0ps33kulFyrGGFDk,11312
|
|
12
|
+
wordpress_markdown_blog_loader-1.6.3.dist-info/METADATA,sha256=vJ36BhU36GL61p9F1LYj8JnKSF_SQgg_LkfPJhBxJJw,14115
|
|
13
|
+
wordpress_markdown_blog_loader-1.6.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
wordpress_markdown_blog_loader-1.6.3.dist-info/entry_points.txt,sha256=l1MVOEAqIY1iJH0RJW7_qCv5H0smaZK8tW_pGeQXIpY,71
|
|
15
|
+
wordpress_markdown_blog_loader-1.6.3.dist-info/top_level.txt,sha256=-eIuwLAkSLPvVQAkMHr0d7dylVlN1iV4ynCWH2XXvsE,31
|
|
16
|
+
wordpress_markdown_blog_loader-1.6.3.dist-info/RECORD,,
|