Line | acb84c08c1bbcc83036b9cad70f6a3c5d60865c4 | current |
1 | <!DOCTYPE html> | <!DOCTYPE html> |
2 | <html> | <html> |
3 | <head> | <head> |
⋮ | ⋮ | ⋮ |
40 | <!-- end icons / favicons --> | <!-- end icons / favicons --> |
41 | | |
42 | <link rel="alternate" type="application/rss+xml" title="tech.interfluidity.com updates" href="../../../../feed/index.rss"> | <link rel="alternate" type="application/rss+xml" title="tech.interfluidity.com updates" href="../../../../feed/index.rss"> |
43 | | <link rel="alternate" type="application/x-all-item-rss+xml" title="tech.interfluidity.com - all items" href="https://tech.interfluidity.com/all-item-feed/index.rss"> |
44 | <link rel="alternate" type="application/rss+xml" title="interfluidity - all blogs" href="https://www.interfluidity.com/unify-rss/all-blogs.rss"> | <link rel="alternate" type="application/rss+xml" title="interfluidity - all blogs" href="https://www.interfluidity.com/unify-rss/all-blogs.rss"> |
45 | <link rel="alternate" type="application/rss+xml" title="interfluidity - all blogs and microblogs" href="https://www.interfluidity.com/unify-rss/all-blogs-and-microblogs.rss"> | <link rel="alternate" type="application/rss+xml" title="interfluidity - all blogs and microblogs" href="https://www.interfluidity.com/unify-rss/all-blogs-and-microblogs.rss"> |
46 | | <link rel="alternate" type="application/x-single-item-rss+xml" title="Feedletter tutorial" href="index.rss"> |
47 | <link rel="stylesheet" href="../../../../css/style.css"> | <link rel="stylesheet" href="../../../../css/style.css"> |
48 | <link rel="stylesheet" href="../../../../css/highlightjs/steve-night-owl.css"><!-- theme for highlight.js --> | <link rel="stylesheet" href="../../../../css/highlightjs/steve-night-owl.css"><!-- theme for highlight.js --> |
49 | <script src="../../../../js/highlight/highlight.min.js"></script> | <script src="../../../../js/highlight/highlight.min.js"></script> |
⋮ | ⋮ | ⋮ |
84 | <h1><a href="index.html">Feedletter tutorial</a></h1> | <h1><a href="index.html">Feedletter tutorial</a></h1> |
85 | <hr class="below-title"> | <hr class="below-title"> |
86 | </div> | </div> |
87 | | <div class="update-prepend rss-description-exclude"> |
88 | | <em> ➣ This post was meaningfully revised at 2024-06-20 @ 01:10 PM EDT. The previous revision is <a href="index-oldcommit-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4.html">here</a>, diff <a href="index-diff-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4-to-current.html">here</a>. (See <a href="index.html#update-history">update history</a>.) </em> |
89 | | <hr> |
90 | | </div> |
91 | <div class="entry-body"> | <div class="entry-body"> |
92 | <div class="flexmark markdown"> | <div class="flexmark markdown"> |
93 | <p>I've been working for some time on a service to turn RSS feeds into e-mail newsletters, which I've called <a href="https://github.com/swaldman/feedletter"><em>feedletter</em></a>.</p> | <p>I've been working for some time on a service to turn RSS feeds into e-mail newsletters, which I've called <a href="https://github.com/swaldman/feedletter"><em>feedletter</em></a>.</p> |
⋮ | ⋮ | ⋮ |
120 | <li><a href="#conclusion">Conclusion</a></li> | <li><a href="#conclusion">Conclusion</a></li> |
121 | </ol> | </ol> |
122 | <h2><a href="#1-set-up-a-server-with-a-dns-name" id="1-set-up-a-server-with-a-dns-name" name="1-set-up-a-server-with-a-dns-name" class="anchorlink"></a>1. Set up a server with a DNS name</h2> | <h2><a href="#1-set-up-a-server-with-a-dns-name" id="1-set-up-a-server-with-a-dns-name" name="1-set-up-a-server-with-a-dns-name" class="anchorlink"></a>1. Set up a server with a DNS name</h2> |
123 | <p>We launch a "droplet" from <a href="https://www.digitalocean.com/">Digital Ocean</a>. You can use whatever Linux flavor you like. We'll pick the latest Ubuntu.</p><img alt="Screenshot of Digital Ocean droplet setup" src="droplet-setup.png" style="width: 100%;"> | <p>We launch a "droplet" from <a href="https://www.digitalocean.com/">Digital Ocean</a>. You can use whatever Linux flavor you like. We'll pick the latest Ubuntu.</p> |
124 | <p>And we go ahead and give it a name.</p><img alt="Screenshot of FastMail DNS setup" src="dns-setup.png" style="width: 100%;"> | <img alt="Screenshot of Digital Ocean droplet setup" src="droplet-setup.png" style="width: 100%;"> |
125 | | <p>And we go ahead and give it a name.</p> |
126 | | <img alt="Screenshot of FastMail DNS setup" src="dns-setup.png" style="width: 100%;"> |
127 | <h2><a href="#2-download-dependencies" id="2-download-dependencies" name="2-download-dependencies" class="anchorlink"></a>2. Download dependencies</h2> | <h2><a href="#2-download-dependencies" id="2-download-dependencies" name="2-download-dependencies" class="anchorlink"></a>2. Download dependencies</h2> |
128 | <p>We login as root to our new droplet (however we've configured that), and download a bunch of stuff we'll need:</p> | <p>We login as root to our new droplet (however we've configured that), and download a bunch of stuff we'll need:</p> |
129 | <pre><code class="language-plaintext"># apt install postgresql | <pre><code class="language-plaintext"># apt install postgresql |
⋮ | ⋮ | ⋮ |
516 | <p>So, by default, this feed will wait at least 30 minutes before notifying, and require a post to have been stable for at least 15 minutes. After 180 minutes, it will be considered final no matter what. It will be checked every approximately 10 minutes.</p> | <p>So, by default, this feed will wait at least 30 minutes before notifying, and require a post to have been stable for at least 15 minutes. After 180 minutes, it will be considered final no matter what. It will be checked every approximately 10 minutes.</p> |
517 | <p>If you don't like these values, you can change them any time with the <code>./feedletter alter-feed</code> command.</p> | <p>If you don't like these values, you can change them any time with the <code>./feedletter alter-feed</code> command.</p> |
518 | <div class="note"> | <div class="note"> |
519 | I am not republishing these blogs without permission. That would be icky. I'm using these feeds for demonstration purposes. I'll be their only e-mail subscriber. | I am not republishing these blogs without permission. That would be icky. I'm using these feeds for demonstration purposes. I'll be their only e-mail subscriber. |
520 | <p>By the time you read this tutorial, <code>play.feedletter.org</code> will have been sadly retired.</p> | <p>By the time you read this tutorial, <code>play.feedletter.org</code> will have been sadly retired.</p> |
521 | </div> | </div> |
522 | <p>Let's add another feed to watch, Atrios' <i>Eschaton</i> blog, whose feed URL is <a href="https://www.eschatonblog.com/feeds/posts/default?alt=rss"><code>https://www.eschatonblog.com/feeds/posts/default?alt=rss</code></a>. I'm just going to stick with the default timings for now:</p> | <p>Let's add another feed to watch, Atrios' <i>Eschaton</i> blog, whose feed URL is <a href="https://www.eschatonblog.com/feeds/posts/default?alt=rss"><code>https://www.eschatonblog.com/feeds/posts/default?alt=rss</code></a>. I'm just going to stick with the default timings for now:</p> |
⋮ | ⋮ | ⋮ |
716 | <input name="main-submit" value="Subscribe!" type="submit"> | <input name="main-submit" value="Subscribe!" type="submit"> |
717 | </form> | </form> |
718 | </code></pre> | </code></pre> |
719 | <div class="note"> | <div class="note" <p> |
720 | <p>As of <em>feedletter v0.0.8</em>, you can use <code>method="POST"</code> in subscribe forms.</p> | As of <em>feedletter v0.0.8</em>, you can use <code>method="POST"</code> in subscribe forms. |
721 | | <p></p> |
722 | <p>Using <code>method="GET"</code> (and therefore also simulating form submission by pasting a URL) remain supported as well.</p> | <p>Using <code>method="GET"</code> (and therefore also simulating form submission by pasting a URL) remain supported as well.</p> |
723 | </div> | </div> |
724 | <p>(You can see live examples of <em>feedletter</em> subscription forms on the <a href="../../../../subscribe.html">subscribe page</a> of this site!)</p> | <p>(You can see live examples of <em>feedletter</em> subscription forms on the <a href="../../../../subscribe.html">subscribe page</a> of this site!)</p> |
725 | <p>We will fake hitting the form above just by pasting the following URL into our browser:</p> | <p>We will fake hitting the form above just by pasting the following URL into our browser:</p> |
726 | <pre><code class="language-plaintext">https://play.feedletter.org/v0/subscription/create?subscribableName=lgm&addressPart=swaldman@mchange.com&displayNamePart=Steve | <pre><code class="language-plaintext">https://play.feedletter.org/v0/subscription/create?subscribableName=lgm&addressPart=swaldman@mchange.com&displayNamePart=Steve |
727 | </code></pre> | </code></pre> |
728 | <p>We are immediately informed of our success: <img alt="Screenshot of 'Subscription Created' page" src="subscription-created.png" style="width: 100%;"> And, you've got mail!</p><img alt="Screenshot of e-mail requesting subscription confirmation" src="please-confirm.png" style="width: 100%;"> | <p>We are immediately informed of our success: <img alt="Screenshot of 'Subscription Created' page" src="subscription-created.png" style="width: 100%;"> And, you've got mail!</p> |
729 | <p>We hit the confirm link and we're done:</p><img alt="Screenshot of 'Subscription confirmed!' page" src="confirmed.png" style="width: 100%;"> | <img alt="Screenshot of e-mail requesting subscription confirmation" src="please-confirm.png" style="width: 100%;"> |
730 | | <p>We hit the confirm link and we're done:</p> |
731 | | <img alt="Screenshot of 'Subscription confirmed!' page" src="confirmed.png" style="width: 100%;"> |
732 | <p>We've made two more subscribables we'll want to test, whose let's-fake-a-form URLs will be</p> | <p>We've made two more subscribables we'll want to test, whose let's-fake-a-form URLs will be</p> |
733 | <pre><code class="language-plaintext">https://play.feedletter.org/v0/subscription/create?subscribableName=lgm-daily&addressPart=swaldman@mchange.com&displayNamePart=Steve | <pre><code class="language-plaintext">https://play.feedletter.org/v0/subscription/create?subscribableName=lgm-daily&addressPart=swaldman@mchange.com&displayNamePart=Steve |
734 | https://play.feedletter.org/v0/subscription/create?subscribableName=atrios-three&addressPart=swaldman@mchange.com&displayNamePart=Steve | https://play.feedletter.org/v0/subscription/create?subscribableName=atrios-three&addressPart=swaldman@mchange.com&displayNamePart=Steve |
⋮ | ⋮ | ⋮ |
829 | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit) | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit) |
830 | Starting single-page webserver on interface '0.0.0.0', port 45612... | Starting single-page webserver on interface '0.0.0.0', port 45612... |
831 | </code></pre> | </code></pre> |
832 | <p>Great. Now let's see how our newsletter looks, with its HTML served on <code>http://play.feedletter.org:45612/</code>. Not so good!</p><img alt="Screenshot of web-served lgm newsletter via ./feedletter-style compose-single, with a badly formatted image" src="scotus-disqualify-bad.png" style="width: 100%;"> | <p>Great. Now let's see how our newsletter looks, with its HTML served on <code>http://play.feedletter.org:45612/</code>. Not so good!</p> |
833 | | <img alt="Screenshot of web-served lgm newsletter via ./feedletter-style compose-single, with a badly formatted image" src="scotus-disqualify-bad.png" style="width: 100%;"> |
834 | <p>(<strong>Update:</strong> As of <em>feedletter v0.0.8</em> you can also <a href="../../../02/04/style-by-mail-in-feedletter/index.html">style newsletters by e-mail</a>, in addition to hitting a development webserver with a browser.)</p> | <p>(<strong>Update:</strong> As of <em>feedletter v0.0.8</em> you can also <a href="../../../02/04/style-by-mail-in-feedletter/index.html">style newsletters by e-mail</a>, in addition to hitting a development webserver with a browser.)</p> |
835 | <div class="note"> | <div class="note"> |
836 | <p>By default, we just pulled the first item (and most recent, since blogs are usually reverse-chronological) from the feed. We can also pull a random item off the feed to view with <code>--random</code>, or a particular item identified by its <code><guid></code> element in the feed with <code>--guid <guid></code>.</p> | <p>By default, we just pulled the first item (and most recent, since blogs are usually reverse-chronological) from the feed. We can also pull a random item off the feed to view with <code>--random</code>, or a particular item identified by its <code><guid></code> element in the feed with <code>--guid <guid></code>.</p> |
⋮ | ⋮ | ⋮ |
893 | } | } |
894 | </style> | </style> |
895 | </code></pre> | </code></pre> |
896 | <p>We save, and hit reload on our browser still pointed at <code>http://play.feedletter.org:45612/</code>, and see...</p><img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good.png" style="width: 100%;"> | <p>We save, and hit reload on our browser still pointed at <code>http://play.feedletter.org:45612/</code>, and see...</p> |
897 | | <img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good.png" style="width: 100%;"> |
898 | <p>Much better!</p> | <p>Much better!</p> |
899 | <p>If we are very picky, we see that at the end of our post, there is a line that doesn't logically belong <em>in</em> the post, and should be italicized or something.</p><img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good-but-bad-endline.png" style="width: 100%;"> | <p>If we are very picky, we see that at the end of our post, there is a line that doesn't logically belong <em>in</em> the post, and should be italicized or something.</p> |
900 | | <img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good-but-bad-endline.png" style="width: 100%;"> |
901 | <p>If we view the source, we'll find it's the last <code><p></code> element in <code><div class="item-contents"></code>. So we modify our styling as follows:</p> | <p>If we view the source, we'll find it's the last <code><p></code> element in <code><div class="item-contents"></code>. So we modify our styling as follows:</p> |
902 | <pre><code class="language-html"><html> | <pre><code class="language-html"><html> |
903 | <head> | <head> |
⋮ | ⋮ | ⋮ |
913 | } | } |
914 | </style> | </style> |
915 | </code></pre> | </code></pre> |
916 | <p>Looks better!</p><img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good-good-endline.png" style="width: 100%;"> | <p>Looks better!</p> |
917 | | <img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good-good-endline.png" style="width: 100%;"> |
918 | <p>We can keep editing all we like. We add the <code>--random</code> flag and run our <code>./feedletter-style</code> command over and over to make sure that posts in general render well.</p> | <p>We can keep editing all we like. We add the <code>--random</code> flag and run our <code>./feedletter-style</code> command over and over to make sure that posts in general render well.</p> |
919 | <p>When we are happy, we want to tell our subscription to use the new untemplate.</p> | <p>When we are happy, we want to tell our subscription to use the new untemplate.</p> |
920 | <p>Remember, the name of the untemplate we've been editing was <code>tutorial.lgmCompose_html</code>.</p> | <p>Remember, the name of the untemplate we've been editing was <code>tutorial.lgmCompose_html</code>.</p> |
⋮ | ⋮ | ⋮ |
997 | </ul> | </ul> |
998 | <p>For each subscribable, you can define just one of each kind of customizer, but customers can perform any number of steps internally.</p> | <p>For each subscribable, you can define just one of each kind of customizer, but customers can perform any number of steps internally.</p> |
999 | <p>For an example, we'll build a content customizer. Both of our feeds frequently embed YouTube videos as <code>iframe</code> HTML elements in their blog posts. Unfortunately, mail clients generally do not render this form of embedded content, leaving awkward empty-spaces in and sometimes mangling the formatting of our newsletters.</p> | <p>For an example, we'll build a content customizer. Both of our feeds frequently embed YouTube videos as <code>iframe</code> HTML elements in their blog posts. Unfortunately, mail clients generally do not render this form of embedded content, leaving awkward empty-spaces in and sometimes mangling the formatting of our newsletters.</p> |
1000 | | <div class="note"> |
1001 | | <p>As of <code>feedletter-v0.0.13</code> (released June 19, 2024), the API has changed slightly from that documented in this tutorial.</p> |
1002 | | <ol> |
1003 | | <li> |
1004 | | <p><code>Customizer</code> is no longer in the package <code>com.mchange.feedletter.style</code>, but in the base <code>com.mchange.feedletter</code> package. (This is because customizers now apply more broadly than styling nowtifications. They can be used, for example, to filter subscribables by author or category.)</p> |
1005 | | </li> |
1006 | | <li> |
1007 | | <p>Individual <code>Customizer</code> types — which are just functions — no longer include a <code>withinTypeId : String</code> argument. <code>withinTypeId</code> is how <code>feedletter</code> binds multiple items into a single notification (for example in a weekly digest feed). The several posts that will be notified share a <code>withinTypeId</code>. However, the nature and format of these IDs are really implementation details of <code>feedletter</code> and its <code>SubscriptionManager</code> classes, so we are not exposing them to customizers.</p> |
1008 | | </li> |
1009 | | </ol> |
1010 | | <p>Just use <code>com.mchange.feedletter.Customizer</code>, and</p> |
1011 | | <pre><code class="language-scala">( subscribableName : SubscribableName, subscriptionManager : SubscriptionManager, feedUrl : FeedUrl, contents : Seq[ItemContent] ) => Seq[ItemContent] |
1012 | | </code></pre> |
1013 | | <p>under newer versions of <code>feedletter</code>.</p> |
1014 | | </div> |
1015 | <p>So let's build a content customizer that replaces these with well-behaved <code>div</code> elements containing links to the resources that would have been in the <code>iframe</code>. We'll include a <code>class="embedded"</code> attribute on the <code>div</code> elements, so that we will be able to style them however we want.</p> | <p>So let's build a content customizer that replaces these with well-behaved <code>div</code> elements containing links to the resources that would have been in the <code>iframe</code>. We'll include a <code>class="embedded"</code> attribute on the <code>div</code> elements, so that we will be able to style them however we want.</p> |
1016 | <p>Writing customizers in writing Scala code. We'll use the excellent <a href="https://jsoup.org/">jsoup</a> library to manipulate HTML. We'll give ourselves space to work by creating a <code>tutorial</code> package in our installation's <code>src</code> directory, and then exiting a file called <code>core.scala</code> inside that.</p> | <p>Writing customizers in writing Scala code. We'll use the excellent <a href="https://jsoup.org/">jsoup</a> library to manipulate HTML. We'll give ourselves space to work by creating a <code>tutorial</code> package in our installation's <code>src</code> directory, and then exiting a file called <code>core.scala</code> inside that.</p> |
1017 | <pre><code class="language-plaintext">$ mkdir src/tutorial | <pre><code class="language-plaintext">$ mkdir src/tutorial |
⋮ | ⋮ | ⋮ |
1082 | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit) | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit) |
1083 | Starting single-page webserver on interface '0.0.0.0', port 45612... | Starting single-page webserver on interface '0.0.0.0', port 45612... |
1084 | </code></pre> | </code></pre> |
1085 | <p>We can find one of Atrios' "Rock on." posts, which used to render blank in mail clients, but now render like...</p><img alt="Screenshot of a transformed-to-div iframe" src="rock-on.png" style="width: 100%;"> | <p>We can find one of Atrios' "Rock on." posts, which used to render blank in mail clients, but now render like...</p> |
1086 | | <img alt="Screenshot of a transformed-to-div iframe" src="rock-on.png" style="width: 100%;"> |
1087 | <p>Of course we can style that <code>div</code> and link however we like.</p> | <p>Of course we can style that <code>div</code> and link however we like.</p> |
1088 | <div class="note"> | <div class="note"> |
1089 | <p><a name="templating-note" href=""></a>Re: "TemplateParams" customizers</p> | <p><a name="templating-note" href=""></a>Re: "TemplateParams" customizers</p> |
⋮ | ⋮ | ⋮ |
1104 | <p>One feedletter instance can host as many feeds and subscribables as you like.</p> | <p>One feedletter instance can host as many feeds and subscribables as you like.</p> |
1105 | <p>Restyling your subscribables, or writing customizers and bespoke untemplates for them, can take longer. Developing custom front-ends is time-consuming detail work.</p> | <p>Restyling your subscribables, or writing customizers and bespoke untemplates for them, can take longer. Developing custom front-ends is time-consuming detail work.</p> |
1106 | <p>I'd love it if you gave <em>feedletter</em> a try!</p> | <p>I'd love it if you gave <em>feedletter</em> a try!</p> |
1107 | | <hr> |
1108 | | <p><strong>Update:</strong> I've <a href="../../../../2025/01/14/syndicating-rss-to-mastodon-and-bluesky-with-feedletter/index.html">added a tutorial</a> on using feedletter to syndicate post announcements from RSS to Mastodon and BlueSky.</p> |
1109 | </div> | </div> |
1110 | </div> | </div> |
1111 | <div class="entry-footer"> | <div class="entry-footer"> |
1112 | <div class="post-metainfo"> | <div class="post-metainfo"> |
1113 | <a href="index.html">10:30 AM EST</a> | <div class="updated-note"> |
1114 | | <a href="index.html#major-updates">Last major update at 2024-06-20 @ 01:10 PM EDT</a> |
1115 | | </div> |
1116 | | <div> |
1117 | | <a href="index.html" class="pubtime">10:30 AM EST</a> |
1118 | | </div> |
1119 | </div> | </div> |
1120 | </div> | </div> |
1121 | </article> | </article> |
⋮ | ⋮ | ⋮ |
1131 | <a href="../../../02/04/style-by-mail-in-feedletter/index.html">Style-by-mail in feedletter →</a> | <a href="../../../02/04/style-by-mail-in-feedletter/index.html">Style-by-mail in feedletter →</a> |
1132 | </div> | </div> |
1133 | </div> | </div> |
1134 | </div><!-- after-article --> | <div id="update-history" class="update-history"> |
1135 | | <h3 class="update-history-title"><a id="major-updates" href=""></a>Major revisions:</h3> |
1136 | | <ul> |
1137 | | <li><span class="update-timestamp"><i>2024-06-20 @ 01:10 PM EDT</i></span> — Add note to Section 16, "Advanced: Customize the content" documenting <i>feedletter</i> API changes that slightly modify this section of the tutorial. (<a href="index-diff-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4-to-current.html">diff</a>)</li> |
1138 | | <li><span class="update-timestamp"><i> <a href="index-oldcommit-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4.html">2024-01-29 @ 10:30 AM EST</a></i></span> — Initial publication.</li> |
1139 | | </ul> |
1140 | | <div class="update-history-note"> |
1141 | | Timestamps represent "major", substantative revisions. There may have been subsequent typo fixes and language reworkings within a major revision, after the time displayed. For a more complete and fine-grained update history, you can view the <a href="https://github.com/swaldman/tech.interfluidity.com/commits/main/">git repository commit history</a>. The most recent minor modification of this entry occurred 2025-01-14 @ 04:05 PM EST. |
1142 | | </div> |
1143 | | </div> |
1144 | | </div> |
1145 | | <!-- after-article --> |
1146 | </div> | </div> |
1147 | <div id="right-sidebar"> | <div id="right-sidebar"> |
1148 | </div> | </div> |
1149 | </div> | </div> |
1150 | </body> | </body> |
1151 | </html> | </html> |