jekyll-devto
Cross-post a Jekyll blog to dev.to, complete, through dev.to's own RSS import.
dev.to can import posts from your feed, but with a typical Jekyll feed you get:
-
Cut-off posts. Feeds that carry a summary, or an empty
<content src="...">link (the Chirpy theme's feed does this), import as the summary only. -
Line numbers inside the code. Rouge with
line_numbers: true, or a{% highlight ruby linenos %}tag, renders the code as a table with a gutter, and dev.to's HTML to Markdown conversion keeps the numbers as code. - Broken links and images. Root-relative URLs point nowhere once the post lives on dev.to.
- Drafts you publish by hand. dev.to always imports as drafts.
This gem fixes all four:
- A generator that adds
/devto.xml, an RSS feed with the full rendered post, plain code blocks and absolute URLs. - A
jekyll-devto publishcommand that publishes the imported drafts once their post is live on your site.
It works from the HTML Jekyll already rendered, so anything your theme and Kramdown support comes through. It never edits your posts.
Install
# Gemfile
gem 'jekyll-devto'# _config.yml
url: "https://example.com" # required: links in the feed are absolute
plugins:
- jekyll-devtoBuild, and the feed is at https://example.com/devto.xml.
On dev.to, go to Settings → Extensions → Publishing to DEV Community from RSS, set the feed URL
to your devto.xml, and turn on Mark the RSS source as canonical URL by default so search
engines treat your site as the original.
Configuration
All optional.
devto:
path: "/devto.xml" # where the feed is written
limit: 20 # newest posts to include (default: all)
cover: image # use each post's image as its dev.to cover (default: no cover)To keep a post off dev.to, set devto: false in its front matter.
Cover image. No cover by default. Many Open Graph images, including the ones 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:
-
devto_cover:on a post sets that post's cover, and always wins.devto_cover: trueuses the post'simage, anddevto_cover: falseturns the cover off for that post. -
cover: imageunderdevto:in_config.ymluses each post'simage(a path, a URL, or{ path: ... }) for every post, for sites whose images have no title on them.
devto_cover: /assets/img/devto-cover.png # optional, per postjekyll-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.
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.
devto_tags: [ruby, rails, jekyll, opensource]
devto_series: "Rails upgrades"Publishing the drafts
export DEVTO_API_KEY=... # https://dev.to/settings/extensions, "DEV Community API Keys"
bundle exec jekyll-devto publish # dry run: prints what it would publish
bundle exec jekyll-devto publish --publish # publishes
bundle exec jekyll-devto publish --days 14 --feed https://example.com/devto.xmlIt reads your live feed (url + devto.path from _config.yml, or --feed), keeps the posts
published in the last --days days (7 by default, never future-dated ones), finds the draft dev.to
imported from each one, and publishes it.
- Drafts are matched by
canonical_urlfirst, then by title. - Older drafts are left alone on purpose. The feed carries your whole archive, and publishing every match would push years of old posts to dev.to at once.
- A post dev.to rejects is reported and the run moves on. Afterwards the drafts are listed again, and any post still among them fails the run. The command exits 1 if anything failed.
On a schedule with GitHub Actions
examples/devto-publish.yml runs it after each deploy and once a day.
Add the key as a repository secret:
gh secret set DEVTO_API_KEYdev.to behaviour worth knowing
Checked against Forem's source, the software dev.to runs:
-
Drafts match on title or link. dev.to skips a feed entry when you already have an article with
the same title or link (
Feeds::CheckItemPreviouslyImported). A deleted draft is imported again on the next fetch while its post is still in the feed. -
The imported body says
published: falsein its own front matter, and that wins over the API'spublishedfield (Article#evaluate_front_matter). The publish command flips it inside the body. A plainpublished: truerequest leaves the post a draft. -
The import drops the language of every code block. dev.to removes every
classattribute before converting (Feeds::CleanHtml), so a fence never gets a language from the feed. The feed marks each block withdata-lang, which survives, andjekyll-devto publishwrites that language into the draft's fences, matching blocks by their first line of code. -
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. -
Only the first four tags are kept, stripped to letters and digits and lowercased. Use
devto_tagsto choose them. - "Replace self-referential links with DEV-specific links" rewrites links between your posts to their dev.to articles at import time, drafts included. Publish the linked post first, or leave that option off.
-
The one-time "Import from XML" box takes at most 25 entries and 500 KB
(
Feeds::ImportFromXml). Usedevto.limitif you need it.
Development
bundle install
bundle exec rake testThe feed tests build a fixture site and replay dev.to's import (Feedjira, Forem's class stripping, ReverseMarkdown) to check what dev.to would store.
Releases
jekyll-devto follows Semantic Versioning, and releases are automated with release-please from Conventional Commits:
-
fix:bumps the PATCH version, and so doperf:,refactor:anddocs: -
feat:bumps the MINOR version -
BREAKING CHANGE:in the commit footer bumps the MAJOR version (the MINOR one while the version is0.x) -
test:,ci:andchore:are left out of the CHANGELOG and do not trigger a release on their own
Steps to release a new version
- Merge pull requests to
mainwith Conventional Commit titles (fix(html): ...,feat(publisher): ...) - release-please keeps a
chore(main): release x.y.zpull request open with the version bump inlib/jekyll/devto/version.rband the newCHANGELOG.mdentries - Review and merge that pull request
- The
Releaseworkflow tagsvx.y.z, creates the GitHub release, runs the tests, and pushes the gem to RubyGems with trusted publishing, so no API key or MFA code is needed
License
MIT