jekyll-devto 0.1.0 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +25 -3
- data/lib/jekyll/devto/feed.xml +31 -2
- data/lib/jekyll/devto/html.rb +86 -10
- data/lib/jekyll/devto/publisher.rb +89 -3
- data/lib/jekyll/devto/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5d68c73dff2c64c76de9bd3e8fc0d9e130d89cc9abc0f49473b69ce4d55a2e3
|
|
4
|
+
data.tar.gz: 791dfd16298f0b7d0ca0126594419a812fc3a84aa3da46e0c8da7690a6a45c0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38c78047e58a1557a7327d3b95104342bf5920ce365dfb3ea3c8665227ab83297c0c9bed6dd0e30ea1a499e5af28d8b7b7fcc24374c5e234384dec4116c722c2
|
|
7
|
+
data.tar.gz: 92cc3df16600b846a8a08868033f54e051e7415d747671a0e88e730c95f7e220e942eb14b3be61700e181a08a220db23ec9ea4836140b1cc07220c674231d3d4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/JuanVqz/jekyll-devto/compare/v0.2.0...v0.3.0) (2026-10-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* **feed:** posts no longer get their image as the dev.to cover by default. Set devto: { cover: image } in _config.yml to keep the 0.2.0 behavior.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **feed:** add a dev.to cover only when asked for ([#14](https://github.com/JuanVqz/jekyll-devto/issues/14)) ([9e9b43f](https://github.com/JuanVqz/jekyll-devto/commit/9e9b43f9422cee3ef4fd7a47aa483347bf5bfde8))
|
|
13
|
+
|
|
14
|
+
## [0.2.0](https://github.com/JuanVqz/jekyll-devto/compare/v0.1.0...v0.2.0) (2026-10-02)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* choose dev.to tags and series from the post's front matter ([#13](https://github.com/JuanVqz/jekyll-devto/issues/13)) ([1aa48f7](https://github.com/JuanVqz/jekyll-devto/commit/1aa48f7a362781f660a54d6e041f043db5a11eb8))
|
|
20
|
+
* **html:** bring code block languages to dev.to ([#11](https://github.com/JuanVqz/jekyll-devto/issues/11)) ([2e62edb](https://github.com/JuanVqz/jekyll-devto/commit/2e62edb47809cc945af3988973e6c44ad9fe2a27))
|
|
21
|
+
* **publisher:** use the post image as the dev.to cover ([#12](https://github.com/JuanVqz/jekyll-devto/issues/12)) ([90707e7](https://github.com/JuanVqz/jekyll-devto/commit/90707e71fbaa5d309bed29ddc455c04bea07237d))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Documentation
|
|
25
|
+
|
|
26
|
+
* add gem version and CI badges to the README ([#8](https://github.com/JuanVqz/jekyll-devto/issues/8)) ([a0d2a9c](https://github.com/JuanVqz/jekyll-devto/commit/a0d2a9c47a871597bd4d3f4c1ac113c18b41a323))
|
|
27
|
+
|
|
3
28
|
## 0.1.0 (2026-10-01)
|
|
4
29
|
|
|
5
30
|
|
data/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# jekyll-devto
|
|
2
2
|
|
|
3
|
+
[](https://badge.fury.io/rb/jekyll-devto)
|
|
4
|
+
[](https://github.com/JuanVqz/jekyll-devto/actions/workflows/ci.yml)
|
|
5
|
+
|
|
3
6
|
Cross-post a Jekyll blog to [dev.to](https://dev.to), complete, through dev.to's own RSS import.
|
|
4
7
|
|
|
5
8
|
dev.to can import posts from your feed, but with a typical Jekyll feed you get:
|
|
@@ -46,10 +49,29 @@ All optional.
|
|
|
46
49
|
devto:
|
|
47
50
|
path: "/devto.xml" # where the feed is written
|
|
48
51
|
limit: 20 # newest posts to include (default: all)
|
|
52
|
+
cover: image # use each post's image as its dev.to cover (default: no cover)
|
|
49
53
|
```
|
|
50
54
|
|
|
51
55
|
To keep a post off dev.to, set `devto: false` in its front matter.
|
|
52
56
|
|
|
57
|
+
**Cover image.** No cover by default. Many Open Graph images, including the ones [jekyll-og-image](https://github.com/igor-alexandrov/jekyll-og-image) and Chirpy produce, already carry the post title, which dev.to shows right under the cover, and without a cover dev.to generates its own share image. To add one:
|
|
58
|
+
|
|
59
|
+
- `devto_cover:` on a post sets that post's cover, and always wins. `devto_cover: true` uses the post's `image`, and `devto_cover: false` turns the cover off for that post.
|
|
60
|
+
- `cover: image` under `devto:` in `_config.yml` uses each post's `image` (a path, a URL, or `{ path: ... }`) for every post, for sites whose images have no title on them.
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
devto_cover: /assets/img/devto-cover.png # optional, per post
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`jekyll-devto publish` adds the cover to the draft; a cover already set on the dev.to draft is kept. dev.to crops covers to 1000×420.
|
|
67
|
+
|
|
68
|
+
**Tags and series.** dev.to keeps only the first four tags, as lowercase letters and digits (`tailwind-css` becomes `tailwindcss`). `devto_tags` picks them for dev.to; it is used for the import and also replaces the tags of a draft that was already imported. `devto_series` puts the post in a dev.to series, created if it does not exist; a series already set on the draft is kept.
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
devto_tags: [ruby, rails, jekyll, opensource]
|
|
72
|
+
devto_series: "Rails upgrades"
|
|
73
|
+
```
|
|
74
|
+
|
|
53
75
|
## Publishing the drafts
|
|
54
76
|
|
|
55
77
|
```sh
|
|
@@ -89,9 +111,9 @@ Checked against [Forem's source](https://github.com/forem/forem), the software d
|
|
|
89
111
|
- **The imported body says `published: false` in its own front matter**, and that wins over the
|
|
90
112
|
API's `published` field (`Article#evaluate_front_matter`). The publish command flips it inside the
|
|
91
113
|
body. A plain `published: true` request leaves the post a draft.
|
|
92
|
-
- **
|
|
93
|
-
|
|
94
|
-
- **Only the first four tags are kept**, stripped to letters and digits.
|
|
114
|
+
- **The import drops the language of every code block.** dev.to removes every `class` attribute before converting (`Feeds::CleanHtml`), so a fence never gets a language from the feed. The feed marks each block with `data-lang`, which survives, and `jekyll-devto publish` writes that language into the draft's fences, matching blocks by their first line of code.
|
|
115
|
+
- **Sometimes the import keeps the raw HTML instead of Markdown.** dev.to converts the feed only when block tags outnumber blank lines (`Feeds::AssembleArticleMarkdown#html_content?`). The feed removes blank lines between tags and writes newlines inside code as ` `, so every post takes the Markdown path. Drafts imported before this change may still be raw HTML; their code keeps no language.
|
|
116
|
+
- **Only the first four tags are kept**, stripped to letters and digits and lowercased. Use `devto_tags` to choose them.
|
|
95
117
|
- **"Replace self-referential links with DEV-specific links"** rewrites links between your posts to
|
|
96
118
|
their dev.to articles at import time, drafts included. Publish the linked post first, or leave
|
|
97
119
|
that option off.
|
data/lib/jekyll/devto/feed.xml
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
{%- assign devto = site.devto | default: empty -%}
|
|
3
3
|
{%- comment -%} Not site.url + baseurl: links in content already carry baseurl (relative_url adds it). {%- endcomment -%}
|
|
4
4
|
{%- assign site_url = site.url -%}
|
|
5
|
+
{%- assign site_root = site.url | append: site.baseurl -%}
|
|
5
6
|
{%- assign posts = site.posts | where_exp: "post", "post.devto != false" -%}
|
|
6
7
|
{%- if devto.limit %}{% assign posts = posts | slice: 0, devto.limit %}{% endif %}
|
|
7
|
-
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
8
|
+
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:devto="https://github.com/JuanVqz/jekyll-devto">
|
|
8
9
|
<channel>
|
|
9
10
|
<title>{{ site.title | default: site.name | xml_escape }}</title>
|
|
10
11
|
<link>{{ "/" | absolute_url }}</link>
|
|
@@ -21,11 +22,39 @@
|
|
|
21
22
|
<link>{{ post_url }}</link>
|
|
22
23
|
<guid isPermaLink="true">{{ post_url }}</guid>
|
|
23
24
|
<pubDate>{{ post.date | date_to_rfc822 }}</pubDate>
|
|
24
|
-
{%-
|
|
25
|
+
{%- comment -%} devto_tags first, so new imports already carry the tags chosen for dev.to. {%- endcomment -%}
|
|
26
|
+
{%- assign devto_tags = post.devto_tags | devto_list %}
|
|
27
|
+
{%- assign tags = devto_tags | default: post.tags | default: post.categories %}
|
|
25
28
|
{%- for tag in tags %}
|
|
26
29
|
<category>{{ tag | xml_escape }}</category>
|
|
27
30
|
{%- endfor %}
|
|
28
31
|
<description>{{ post.description | default: post.excerpt | strip_html | strip_newlines | truncatewords: 60 | xml_escape }}</description>
|
|
32
|
+
{%- comment -%}
|
|
33
|
+
The dev.to cover is opt-in: many Open Graph images carry the post title, which dev.to
|
|
34
|
+
already shows under the cover, and without one dev.to generates its own share image.
|
|
35
|
+
devto_cover on a post always wins (false turns it off, true uses the post image);
|
|
36
|
+
`devto: { cover: image }` in _config.yml uses each post's image. Unlike content
|
|
37
|
+
links, image paths leave baseurl out (Chirpy and jekyll-og-image add it when
|
|
38
|
+
rendering), so the cover needs it.
|
|
39
|
+
{%- endcomment -%}
|
|
40
|
+
{%- assign cover = "" %}
|
|
41
|
+
{%- if post.devto_cover == false %}
|
|
42
|
+
{%- elsif post.devto_cover == true %}
|
|
43
|
+
{%- assign cover = post.image | devto_url: site_root %}
|
|
44
|
+
{%- elsif post.devto_cover %}
|
|
45
|
+
{%- assign cover = post.devto_cover | devto_url: site_root %}
|
|
46
|
+
{%- elsif devto.cover == "image" or devto.cover == true %}
|
|
47
|
+
{%- assign cover = post.image | devto_url: site_root %}
|
|
48
|
+
{%- endif %}
|
|
49
|
+
{%- if cover != "" %}
|
|
50
|
+
<devto:cover>{{ cover | xml_escape }}</devto:cover>
|
|
51
|
+
{%- endif %}
|
|
52
|
+
{%- if devto_tags %}
|
|
53
|
+
<devto:tags>{{ devto_tags | join: "," | xml_escape }}</devto:tags>
|
|
54
|
+
{%- endif %}
|
|
55
|
+
{%- if post.devto_series %}
|
|
56
|
+
<devto:series>{{ post.devto_series | xml_escape }}</devto:series>
|
|
57
|
+
{%- endif %}
|
|
29
58
|
<content:encoded>{{ post.content | devto_html: site_url | xml_escape }}</content:encoded>
|
|
30
59
|
</item>
|
|
31
60
|
{%- endfor %}
|
data/lib/jekyll/devto/html.rb
CHANGED
|
@@ -13,12 +13,16 @@ module Jekyll
|
|
|
13
13
|
# up inside the code.
|
|
14
14
|
# - Root-relative links and images point nowhere once the post lives on
|
|
15
15
|
# dev.to.
|
|
16
|
+
#
|
|
17
|
+
# The code language cannot travel as a class, so each block carries it as
|
|
18
|
+
# data-lang, which CleanHtml keeps. ReverseMarkdown ignores it; the
|
|
19
|
+
# publisher reads it from the feed and writes it into the draft's fences.
|
|
16
20
|
module HTML
|
|
17
21
|
# Kramdown block options such as {: .nolineno } or {: file="..." } add
|
|
18
22
|
# classes and attributes to the wrapper, so the class is matched anywhere
|
|
19
23
|
# in the tag.
|
|
20
24
|
ROUGE_BLOCK = %r{
|
|
21
|
-
<div\b[^>]*\bclass="[^"]*\bhighlighter-rouge\b[^"]*"[^>]
|
|
25
|
+
<div\b(?<attrs>[^>]*\bclass="[^"]*\bhighlighter-rouge\b[^"]*"[^>]*)>\s*
|
|
22
26
|
<div\ class="highlight">\s*<pre\ class="highlight"><code>
|
|
23
27
|
(?<body>.*?)
|
|
24
28
|
</code></pre>\s*</div>\s*</div>
|
|
@@ -26,7 +30,7 @@ module Jekyll
|
|
|
26
30
|
|
|
27
31
|
# What the {% highlight %} Liquid tag renders.
|
|
28
32
|
HIGHLIGHT_TAG = %r{
|
|
29
|
-
<figure\ class="highlight"><pre><code\b[^>]
|
|
33
|
+
<figure\ class="highlight"><pre><code\b(?<attrs>[^>]*)>
|
|
30
34
|
(?<body>.*?)
|
|
31
35
|
</code></pre></figure>
|
|
32
36
|
}mx
|
|
@@ -40,31 +44,103 @@ module Jekyll
|
|
|
40
44
|
TAG = /<[a-zA-Z][^>]*>/
|
|
41
45
|
ROOT_RELATIVE = %r{\b(src|href)="/(?!/)}
|
|
42
46
|
|
|
47
|
+
# Kramdown's name for a fence with no language.
|
|
48
|
+
NO_LANGUAGE = %w[plaintext text].freeze
|
|
49
|
+
|
|
43
50
|
module_function
|
|
44
51
|
|
|
45
52
|
def convert(html, base_url)
|
|
46
53
|
html.to_s
|
|
47
|
-
.gsub(ROUGE_BLOCK) { plain_code(Regexp.last_match[:body]) }
|
|
48
|
-
.gsub(HIGHLIGHT_TAG) { plain_code(Regexp.last_match[:body]) }
|
|
54
|
+
.gsub(ROUGE_BLOCK) { plain_code(Regexp.last_match[:body], rouge_language(Regexp.last_match[:attrs])) }
|
|
55
|
+
.gsub(HIGHLIGHT_TAG) { plain_code(Regexp.last_match[:body], Regexp.last_match[:attrs][/\bdata-lang="([^"]+)"/, 1]) }
|
|
49
56
|
.gsub(TAG) { |tag| tag.gsub(ROOT_RELATIVE, %(\\1="#{base_url.to_s.chomp('/')}/)) }
|
|
50
57
|
end
|
|
51
58
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
59
|
+
# Forem converts the HTML to Markdown only when block tags outnumber blank
|
|
60
|
+
# lines (Feeds::AssembleArticleMarkdown#html_content?); otherwise it stores
|
|
61
|
+
# the raw HTML, and none of the conversion (fences, languages) happens.
|
|
62
|
+
# Blank lines between tags are only formatting, and a newline inside code
|
|
63
|
+
# written as is the same character once parsed, so the post reads
|
|
64
|
+
# the same while the count of blank lines drops to zero.
|
|
65
|
+
def without_blank_lines(html)
|
|
66
|
+
html.split(%r{(<pre\b[^>]*>.*?</pre>)}m).map do |part|
|
|
67
|
+
part.start_with?('<pre') ? part.gsub("\n", ' ') : part.gsub(/\n\s*\n/, "\n")
|
|
68
|
+
end.join
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Kramdown writes the fence's language as a language-* class, and the
|
|
72
|
+
# language can hold any non-space character (c#, shell.session). It is
|
|
73
|
+
# read from the class attribute only, so another attribute such as
|
|
74
|
+
# file="docs/language-notes.md" cannot pass for it.
|
|
75
|
+
def rouge_language(attrs)
|
|
76
|
+
attrs[/\bclass="([^"]*)"/, 1].to_s[/(?:\A|\s)language-(\S+)/, 1]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def plain_code(body, language)
|
|
55
80
|
body = Regexp.last_match[:code] if body =~ GUTTER
|
|
81
|
+
attribute = language && !NO_LANGUAGE.include?(language) ? %( data-lang="#{language}") : ''
|
|
82
|
+
|
|
83
|
+
"<pre#{attribute}><code>#{body.gsub(/<[^>]+>/, '')}</code></pre>"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A post image as an absolute URL. Takes a path, a URL, or Chirpy's
|
|
87
|
+
# { "path" => ... } hash; nil when there is nothing to point at.
|
|
88
|
+
def absolute_url(value, base_url)
|
|
89
|
+
path = value.is_a?(Hash) ? value['path'] : value
|
|
90
|
+
return if path.to_s.empty?
|
|
91
|
+
return path if path.match?(%r{\Ahttps?://})
|
|
92
|
+
return "#{base_url.to_s[/\A[a-z][a-z0-9+.-]*:/i] || 'https:'}#{path}" if path.start_with?('//')
|
|
93
|
+
|
|
94
|
+
"#{base_url.to_s.chomp('/')}/#{path.sub(%r{\A/}, '')}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
CODE_BLOCK = %r{<pre\b(?<attrs>[^>]*)><code\b[^>]*>(?<code>.*?)</code></pre>}m
|
|
98
|
+
|
|
99
|
+
# Every code block in a converted body, in order, as [language, first
|
|
100
|
+
# line of code] (language is nil when the block has none). The first line
|
|
101
|
+
# is what identifies the block in the dev.to draft, whose fences do not
|
|
102
|
+
# always line up one to one with the blocks.
|
|
103
|
+
def code_blocks(html)
|
|
104
|
+
html.to_s.scan(CODE_BLOCK).map do |attrs, code|
|
|
105
|
+
[attrs[/\bdata-lang="([^"]+)"/, 1], first_line(unescape(code))]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Whitespace is collapsed for the comparison: some Markdown converters
|
|
110
|
+
# squeeze runs of spaces inside code.
|
|
111
|
+
def first_line(code)
|
|
112
|
+
code.to_s.each_line.map { |line| line.split.join(' ') }.find { |line| !line.empty? }.to_s
|
|
113
|
+
end
|
|
56
114
|
|
|
57
|
-
|
|
115
|
+
# The entities this HTML can carry inside code; enough to compare it with
|
|
116
|
+
# the Markdown dev.to stores. & goes last so "&lt;" stays "<".
|
|
117
|
+
def unescape(text)
|
|
118
|
+
text.gsub(/&#(\d+);/) { Regexp.last_match(1).to_i.chr(Encoding::UTF_8) }
|
|
119
|
+
.gsub('<', '<').gsub('>', '>').gsub('"', '"').gsub(''', "'").gsub('&', '&')
|
|
58
120
|
end
|
|
59
121
|
end
|
|
60
122
|
|
|
61
123
|
# Liquid filter for the feed template.
|
|
62
124
|
module Filters
|
|
125
|
+
# devto_tags as a list, whether the front matter wrote a YAML list or a
|
|
126
|
+
# string ("ruby, rails" or "ruby rails", as Jekyll accepts for tags), so
|
|
127
|
+
# the import and the publisher see the same tags. nil when there are none.
|
|
128
|
+
def devto_list(value)
|
|
129
|
+
list = value.is_a?(Array) ? value.map(&:to_s) : value.to_s.split(/[,\s]+/)
|
|
130
|
+
list = list.map(&:strip).reject(&:empty?)
|
|
131
|
+
list unless list.empty?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def devto_url(value, base_url)
|
|
135
|
+
HTML.absolute_url(value, base_url).to_s
|
|
136
|
+
end
|
|
137
|
+
|
|
63
138
|
def devto_html(html, base_url)
|
|
64
|
-
HTML.convert(html, base_url)
|
|
139
|
+
HTML.without_blank_lines(HTML.convert(html, base_url))
|
|
65
140
|
end
|
|
66
141
|
end
|
|
67
142
|
end
|
|
68
143
|
end
|
|
69
144
|
|
|
70
|
-
|
|
145
|
+
# The CLI loads this file without Jekyll, only for HTML.code_blocks.
|
|
146
|
+
Liquid::Template.register_filter(Jekyll::Devto::Filters) if defined?(Liquid::Template)
|
|
@@ -4,6 +4,7 @@ require 'net/http'
|
|
|
4
4
|
require 'rexml/document'
|
|
5
5
|
require 'time'
|
|
6
6
|
require_relative 'client'
|
|
7
|
+
require_relative 'html'
|
|
7
8
|
|
|
8
9
|
module Jekyll
|
|
9
10
|
module Devto
|
|
@@ -17,7 +18,7 @@ module Jekyll
|
|
|
17
18
|
class Publisher
|
|
18
19
|
MAX_REDIRECTS = 5
|
|
19
20
|
|
|
20
|
-
Post = Struct.new(:title, :link, :date, keyword_init: true)
|
|
21
|
+
Post = Struct.new(:title, :link, :date, :code_blocks, :cover, :tags, :series, keyword_init: true)
|
|
21
22
|
|
|
22
23
|
def initialize(feed:, client:, days: 7, publish: false, now: Time.now, out: $stdout, err: $stderr)
|
|
23
24
|
@feed = feed
|
|
@@ -51,7 +52,7 @@ module Jekyll
|
|
|
51
52
|
|
|
52
53
|
# One rejected post must not stop the rest, or every retry would stop at it.
|
|
53
54
|
begin
|
|
54
|
-
result = @client.update(draft['id'], published: true, body_markdown: self.class.
|
|
55
|
+
result = @client.update(draft['id'], published: true, body_markdown: self.class.prepared_body(draft['body_markdown'], post, cover: new_cover(draft, post)))
|
|
55
56
|
sent[draft['id']] = post
|
|
56
57
|
@out.puts " sent #{post.title.inspect} -> #{result['url']}"
|
|
57
58
|
rescue StandardError => e
|
|
@@ -76,17 +77,102 @@ module Jekyll
|
|
|
76
77
|
end
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# The draft body to send: published, with each code fence given the
|
|
81
|
+
# language its block had on the site.
|
|
82
|
+
def self.prepared_body(markdown, post, cover: post.cover)
|
|
83
|
+
body = published_body(markdown)
|
|
84
|
+
body = with_front_matter(body, 'cover_image', cover) if cover
|
|
85
|
+
tags = devto_tags(post.tags.to_a)
|
|
86
|
+
body = with_front_matter(body, 'tags', tags, replace: true) unless tags.empty?
|
|
87
|
+
body = with_front_matter(body, 'series', yaml_string(post.series)) if post.series
|
|
88
|
+
with_code_languages(body, post.code_blocks.to_a)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Adds `key: value` to the draft's front matter, which dev.to reads on
|
|
92
|
+
# every save (Article#evaluate_front_matter: cover_image, tags, series).
|
|
93
|
+
# A key the draft already has is kept, since it was set on dev.to on
|
|
94
|
+
# purpose, unless `replace:` says the site's value wins (devto_tags,
|
|
95
|
+
# which the author chose for dev.to explicitly).
|
|
96
|
+
def self.with_front_matter(markdown, key, value, replace: false)
|
|
97
|
+
# The key line plus any continuation lines (an indented or "- " list), so
|
|
98
|
+
# a replaced block list does not leave its items behind.
|
|
99
|
+
line = /^#{Regexp.escape(key)}:[^\r\n]*(?:\r?\n(?:[ \t]|-(?=[ \t\r\n]))[^\r\n]*)*/
|
|
100
|
+
markdown.to_s.sub(/\A---(\r?\n).*?^---[ \t]*\r?$/m) do |front_matter|
|
|
101
|
+
newline = Regexp.last_match(1)
|
|
102
|
+
if front_matter.match?(line)
|
|
103
|
+
next replace ? front_matter.sub(line) { "#{key}: #{value}" } : front_matter
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
front_matter.sub(/^---[ \t]*\r?\z/) { |closing| "#{key}: #{value}#{newline}#{closing}" }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The cleanup dev.to's import applies to tags (Feeds::AssembleArticleMarkdown
|
|
111
|
+
# #get_tags): at most four, spaces and anything but letters and digits
|
|
112
|
+
# removed, 20 characters each. Lowercased as dev.to stores them
|
|
113
|
+
# (ActsAsTaggableOn.force_lowercase), so the dry run shows the real tags.
|
|
114
|
+
def self.devto_tags(tags)
|
|
115
|
+
tags.first(4).map { |tag| tag.delete(' ').gsub(/[^[:alnum:]]/, '')[0..19].downcase }.reject(&:empty?).join(',')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A double-quoted YAML scalar, so a value such as "MDN: Spanish" stays a
|
|
119
|
+
# string instead of breaking the front matter. A JSON string is one.
|
|
120
|
+
def self.yaml_string(value)
|
|
121
|
+
JSON.generate(value.to_s)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Writes each block's language into the draft's code fence. Blocks are
|
|
125
|
+
# matched by their first line of code, not by position: dev.to does not
|
|
126
|
+
# turn every block into a fence (one inside a list item can come out
|
|
127
|
+
# unfenced), and an edited draft may not line up either. Each block is
|
|
128
|
+
# used once, in order: a fence only matches blocks after the last one
|
|
129
|
+
# matched, so a block missing from the draft cannot lend its language to
|
|
130
|
+
# a later fence that starts the same way. A fence nothing matches, or
|
|
131
|
+
# one that already names a language, is left alone.
|
|
132
|
+
def self.with_code_languages(markdown, blocks)
|
|
133
|
+
lines = markdown.to_s.lines
|
|
134
|
+
cursor = 0
|
|
135
|
+
inside = false
|
|
136
|
+
lines.each_with_index do |line, index|
|
|
137
|
+
next unless line =~ /\A\s*```[^\s`]*\s*\z/
|
|
138
|
+
|
|
139
|
+
inside = !inside
|
|
140
|
+
next unless inside && line =~ /\A\s*```\s*\z/
|
|
141
|
+
|
|
142
|
+
first = HTML.first_line(lines[(index + 1)..].take_while { |l| l !~ /\A\s*```/ }.join)
|
|
143
|
+
match = (cursor...blocks.size).find { |i| blocks[i][1] == first }
|
|
144
|
+
next unless match
|
|
145
|
+
|
|
146
|
+
cursor = match + 1
|
|
147
|
+
language = blocks[match].first
|
|
148
|
+
lines[index] = line.sub('```', "```#{language}") if language
|
|
149
|
+
end
|
|
150
|
+
lines.join
|
|
151
|
+
end
|
|
152
|
+
|
|
79
153
|
def due_posts
|
|
80
154
|
REXML::Document.new(read_feed).get_elements('//item').filter_map do |item|
|
|
81
155
|
date = Time.rfc2822(item.elements['pubDate'].text)
|
|
82
156
|
next if date > @now || date < @now - (@days * 86_400)
|
|
83
157
|
|
|
84
|
-
Post.new(title: item.elements['title'].text.to_s.strip, link: item.elements['link'].text.to_s.strip, date: date
|
|
158
|
+
Post.new(title: item.elements['title'].text.to_s.strip, link: item.elements['link'].text.to_s.strip, date: date,
|
|
159
|
+
code_blocks: HTML.code_blocks(item.elements['content:encoded']&.text),
|
|
160
|
+
cover: item.elements['devto:cover']&.text,
|
|
161
|
+
tags: item.elements['devto:tags']&.text&.split(','),
|
|
162
|
+
series: item.elements['devto:series']&.text)
|
|
85
163
|
end
|
|
86
164
|
end
|
|
87
165
|
|
|
88
166
|
private
|
|
89
167
|
|
|
168
|
+
# A cover chosen in dev.to's editor lives in the article (main_image),
|
|
169
|
+
# not in the body's front matter, and a cover_image key in the front
|
|
170
|
+
# matter would replace it (Article#set_main_image). The API reports it
|
|
171
|
+
# as cover_image, so a draft that has one is left with it.
|
|
172
|
+
def new_cover(draft, post)
|
|
173
|
+
post.cover if draft['cover_image'].to_s.empty?
|
|
174
|
+
end
|
|
175
|
+
|
|
90
176
|
# Follows redirects (http to https, apex to www), which Net::HTTP does not.
|
|
91
177
|
def read_feed
|
|
92
178
|
return File.read(@feed) unless @feed.match?(%r{\Ahttps?://})
|
data/lib/jekyll/devto/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jekyll-devto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Juan Vásquez
|
|
@@ -48,7 +48,7 @@ description: |
|
|
|
48
48
|
Rouge line numbers, absolute links and images), and a command that
|
|
49
49
|
publishes the imported drafts once their post is live on your site.
|
|
50
50
|
email:
|
|
51
|
-
-
|
|
51
|
+
- hello@juanvasquez.dev
|
|
52
52
|
executables:
|
|
53
53
|
- jekyll-devto
|
|
54
54
|
extensions: []
|