Dev.to user @healeycodes published a lovely article last week showing how to use the dev.to API to embed posts in your page.
Embed Your Latest DEV Posts Anywhere ๐
Andrew Healey ใป Apr 6 '19
His work inspired me to build a (surprise!) web component which would make that even easier. Let's build a dev.to web component!
Overview
Our component will have two parts:
-
<dev-feed>
, a list component -
<dev-article>
, an article component
The main component, <dev-feed>
will be responsible for fetching and sorting the articles, and <dev-article>
will be responsible for displaying each post.
We'll start by building with the container, and work our way down to the details of the article display.
Step 1: Scaffolding
Let's use open-wc's tools to get a head start on our component:
- run
npm init @open-wc
- choose
Scaffold a new project
- choose
Lit Element Web Component
- enter the name
dev-feed
- open your editor
atom -a dev-feed
You'll see two files under src
:
- dev-feed.js
- DevFeed.js
The first, in dash-case, is the file which users will import in order to register the custom element to their page. The second, in PascalCase, contains the element class, extending from LitElement
. If you're not entirely clear on what I mean by those things, check out my post on lit-element. It's cool, I'll wait. You good? alright...
You'll also see a demo folder with an index.html inside. As we write our component, you can run the owc-dev-server on that file to see how your component looks. Open WC's generator already set that up for us, so we can just run.
npm start
We're going to practice a little README-driven-development, or demo-driven-development. Meaning, we'll first decide our element's external API, writing that into our demo; then we'll work on bringing the code up to our standards. Let's use the built-in Proxy constructor and lit-html to hack together a simple purpose-built reactive renderer for our demo, like a kind of chibi-storybook.
const properties = new Proxy({
// initial values
showDescriptions: false,
sort: 'popularity',
username: 'bennypowers'
}, {
/** Render the demo when a value is set */
set(obj, prop, value) {
obj[prop] = value
update();
return true
}
});
This proxy holds a model of our element's properties, and it will call an update
function any time one of our values is set. That update
function will in turn call lit-html's render
function to efficiently update the DOM.
const update = ({ showDescriptions, sort, username } = properties) => render(html`
<dev-feed id="component"
username="${username}"
sort="${sort}"
?show-descriptions="${showDescriptions}"
></dev-feed>
<input id="username"
@change="${onUsernameChange}"
value="${username}"/>
<input id="show-descriptions" type="checkbox"
@change="${onShowDescriptionsChange}"
?checked="${showDescriptions}"/>
<select id="sort" @change="${onSortByChange}" value="${sort}">
<option value="popularity">Popularity</option>
<option value="date">Date, Descending</option>
<option value="date-asc">Date, Ascending</option>
</select>
`, document.body);
Here we provide a few controls to set our component's properties. The event handlers (see repo for source) each grab the relevant value and set it on properties
, which triggers the render via the proxy. Nice ๐.
Implementing our Feed Element
Now that our demo is wired up, it's time to set up our feed element's internal logic and template. We'll start with a simple implementation and work up to the final product, refreshing our demo app as we go.
The first and easiest step will be to define our observed properties.
static get properties() {
return {
loading: { type: Boolean },
posts: { type: Array },
showDescriptions: { type: Boolean, attribute: 'show-descriptions' },
sort: { type: String, reflect: true },
username: { type: String },
}
}
constructor() {
super();
this.posts = [];
this.sort = 'popularity';
}
Note the attribute
specified for showDescriptions
, that's because HTML attributes are always lowercased, so here we explicitly link the dash-case attribute with the camelCase property. We also set some defaults in the constructor, especially for the posts
property, which will be our private list of articles fetched from dev.to.
Next, let's set up the feed components's template. Compared to the article, it has quite simple markup:
render() {
const { loading, posts, postTemplate, sort } = this;
const parseAsTimestamp = s => new Date(s).getTime();
const sorter = (
sort === 'popularity' ? propGt('positive_reactions_count')
: sort === 'date' ? mapPropGt(parseAsTimestamp, 'published_at')
: sort === 'date-asc' ? mapPropLt(parseAsTimestamp, 'published_at')
: identity
);
return html`
<div ?hidden="${!loading}">${loadingTemplate}</div>
<ul id="posts" ?hidden="${loading}">
${posts
.sort(sorter)
.map(postTemplate)}
</ul>
`;
}
postTemplate(post) {
return html`
<li>
<dev-article
.article="${post}"
?show-description="${this.showDescriptions}"
></dev-article>
</li>`;
}
What's happening with the sorter
? Well, the early-days dev.to API doesn't yet have advanced controls on GET /api/articles
, so we're doing some client-side sorting. For this project, I decided to implement the article sorting with some functional JavaScript. mapPropGt
and mapPropLt
both compose a function called mapPropCompare
which, for two input values,
- Gets a property from each input
- Maps some function over that value
- Applies some comparison function to the two terms
The only difference between the two versions is that the less-than variety flips the first two arguments to the comparison function before applying.
const identity = x => x;
const sub = (x, y) => x - y;
const flip = f => (y, x, ...rest) => f(x, y, ...rest);
const mapPropCompare = curry((f, g, prop, x, y) => f(g(y[prop]), g(x[prop])));
const mapPropGt = mapPropCompare(sub);
const mapPropLt = mapPropCompare(flip(sub));
const propGt = mapPropGt(identity);
For a short intro to this style of programming, check out my slide deck Starting Functional JavaScript.
Fetching Posts
Now that we have our basic template set up, let's write the code which will actually fetch posts from dev.to. We'll write four methods to handle this: one to generate a url, one to fetch the posts, and one to assign the results to the component.
get apiEndpoint() {
const { username } = this;
if (!username) return null;
const search = new URLSearchParams({ username });
const API_ENDPOINT = new URL('api/articles', 'https://dev.to');
API_ENDPOINT.search = search;
return API_ENDPOINT;
}
async updated(changed) {
if (changed.has('username')) this.fetchPosts();
}
assignPosts(posts) {
this.posts = posts || [];
this.loading = false;
}
async fetchPosts() {
const handleAsJson = response => response.json();
const { apiEndpoint, assignPosts } = this;
if (!apiEndpoint) return;
this.loading = true;
return fetch(apiEndpoint)
.then(handleAsJson)
.then(assignPosts);
}
We also need to bind assignPosts
and postTemplate
in the constructor so that we can destructure them and pass them around first-class. If we didn't do that, postTemplate
would bind its this
reference to the posts array, and assignPosts
would bind to the fetch promise; and that would just be plain silly.
this.postTemplate = this.postTemplate.bind(this);
this.assignPosts = this.assignPosts.bind(this);
For the URL, I decided to reach for the built-in URL
and URLSearchParams
constructors. We could just as easily have used string interpolation i.e. https://dev.to/api/articles?username=${username}
, but doing it this way lets us easily add more parameters should the need arise. Also makes me feel like I'm getting my money's worth from the built-ins ๐
Debouncing Fetch Requests
The last thing we'll do in the feed component is debounce requests to the server. Debouncing means deferring execution until a certain time has passed since the last call. It's a useful technique when you have an expensive operation (such as fetching data over the network, or certain kinds of paint-heavy DOM updates) that fires based on user input (like typing or scrolling). In effect, we're telling our component: "Fetch articles when the user types in a username, but before committing to send the request, wait half a second to make sure they're finished typing."
import { debounce } from './debounce.js';
/* ... */
constructor() {
super();
/* ... */
this.fetchPosts = debounce(this.fetchPosts.bind(this), 500);
}
If we would reload our demo page now, we wouldn't see anything, because the <dev-article>
component hasn't been defined. But, if we inspected our element's shadow root, we'd see several <dev-article>
elements, each one with its own article
DOM property.
Next we'll get to work laying out each article according to the design on dev.to.
Implementing our Article Element
Whereas the feed element was long on logic and short on presentation, the article element is where we get to hone our semantic-HTML-and-CSS-fu.
Each <dev-article>
element will internally render an <article>
element, with a few more semantic HTML goodies as siblings. We'll use CSS grid to lay everything out without adding extraneous <div>
s.
render() {
const {
cover_image: coverImage,
description,
positive_reactions_count: positiveReactionsCount,
published_at: publishedAt,
tag_list: tagList,
title,
type_of: typeOf,
url,
user: {
name,
profile_image_90: avatar,
username,
},
} = this.article;
return html`
<article aria-labelledby="title">
<figure>
<a id="cover" ?hidden="${!coverImage}" href="${url}" rel="norefer noopener nofollow">
<img src="${coverImage}" role="presentation"/>
</a>
<figcaption>
<a id="title" href="${url}" rel="noopener norefer">
<h3>${title}</h3>
</a>
</figcaption>
</figure>
<a id="avatar" href="https://dev.to/${username}" rel="norefer noopener nofollow">
<img src="${avatar}" alt="${name || username}'s Avatar"/>
</a>
<section id="metadata">
<a href="https://dev.to/${username}" rel="norefer noopener nofollow">
<span>${name || username} โข <time>${formatDate(publishedAt)}</time></span>
<span id="relative-time">(${formatHuman(publishedAt)})</span>
</a>
<ul id="tags">${tagList.map(tagTemplate)}</ul>
<details ?open="${this.showDescription}">
<summary hidden></summary>
${description}
</details>
</section>
<span id="positive-reactions">
<img
src="https://practicaldev-herokuapp-com.freetls.fastly.net/assets/reactions-stack-4bb9c1e4b3e71b7aa135d6f9a5ef29a6494141da882edd4fa971a77abe13dbe7.png"
alt="Circled heart on a stack of similar circles"
title="Number of Positive Reactions"/>
${positiveReactionsCount}
</span>
<section id="actions">
<button @click="${this.toggleDescription}" title="Show Description">๐ฌ</button>
</section>
</article>
`;
}
So this is pretty straightforward semantic HTML, but there are a few goodies to enjoy:
- Let's use lit-element to bind a button elsewhere in the DOM to our
<details>
element'sopen
state. - We'll add a hidden
<summary>
element so that the UA doesn't show us the default disclosure widget. - We'll use the
<time>
element (ten points for you if you already knew this exists) to display the post date. - We'll use named grid areas to define chunks of layout in CSS. See the final code for more.
Final Code
So here's our component, running on glitch.
You can use <dev-feed>
on your page today!
<script src="https://unpkg.com/dev-feed/dev-feed.js?module"></script>
<dev-feed username="bennypowers"></dev-feed>
Or install with npm and build with @pika/web
npm i -S dev-feed
npx @pika/web
<script src="/web_modules/dev-feed.js"></script>
<dev-feed username="bennypowers"></dev-feed>
Ideas for the Future
This was put together pretty quickly, so there's plenty of room to grow. The element's API surface could use some polish (perhaps around showing descriptions or other metadata like user social links), so if you have any ideas please comment, or open issues or PRs.
Another thing which is rife for reconsideration is how this element gets its data. There's no good reason for the work of fetching posts from the API to be a part of the element class. It should rightfully be it's own library. Hey @healeycodes, what do you think about joining forces on some modern devto.js library that abstracts over fetch
and returns a promise of posts?
Thanks for Reading
Thanks to @westbrook for his input on this post and to @healeycodes for getting the ball rolling.
Hope you enjoyed the post and please use <dev-feed>
on your web sites!
Top comments (0)