My fourth challenge for the Vanilla JS Academy was to build a news feed using the Top Stories API from The New York Times. Let's look at how I tackled it.
Before we do anything else, let's look at the HTML I started with:
<header>
<h1>NYT Top Stories</h1>
<p>Here are <a href="https://www.nytimes.com/trending/">today's top stories</a> from <i>The New York Times</i>.</p>
</header>
<main>
<p>
<strong>Getting today's top stories...</strong>
</p>
</main>
I have a link to today's top stories on the New York Times website. I put this there in case the JavaScript fails to load so that the user can still access the content. I also have a main
element into which I will inject the stories.
Getting today's top stories
First, I created two variables: one for the main
element, and one for the API endpoint.
I used Cloudflare Workers to hide my API key, which is why my endpoint is different from the one in the Top Stories API documentation:
// Get the element that will contain the stories
var main = document.querySelector("main");
// Store the endpoint
var endpoint = "https://nyt.kbarker.workers.dev";
Then I added in my getJSON()
helper function...
/**
* Get the JSON data from a Fetch request
* @param {Object} response The response to the request
* @returns {Object} The JSON data OR a rejected promise
*/
function getJSON (response) {
return response.ok ? response.json() : Promise.reject(response);
}
...And made a Fetch request to my endpoint. I used my getJSON()
function to get the data, and the console.log()
method to log it to the console. I also used the console.error()
method to log any errors:
fetch(endpoint)
.then(getJSON)
.then(console.log)
.catch(console.error);
The returned object contains a results
property. It's an array of objects that represent today's top stories.
I created a function that builds an HTML string for each of these objects:
/**
* Build the HTML string for a single story
* @param {Object} story The object returned by the API
* @returns {String} An HTML string
*/
function buildStory (story) {
return (
"<article>" +
"<header>" +
"<h2>" +
"<a href='" + story.url + "'>" + story.title + "</a>" +
"</h2>" +
"<p>" +
"<b>Last updated: </b>" +
"<time datetime='" + story.updated_date + "'>" +
new Date(story.updated_date).toLocaleString() +
"</time>" +
"</p>" +
"</header>" +
"<p>" + story.abstract + "</p>" +
"</article>"
);
}
Then I created another function that:
- Gets the first five story objects (
data.results.slice(0, 5)
) - Converts them into HTML strings (
.map(buildStory)
) - Joins the whole array into one string (
.join("")
) - Adds the final HTML string to the page (
main.innerHTML =
)
/**
* Add the first five stories to the DOM
* @param {Array} data The array of objects returned by the API
*/
function showStories (data) {
main.innerHTML = data.results.slice(0, 5).map(buildStory).join("");
}
I also added a function to show an error message if necessary. This is a better user experience than just logging it to the console:
/**
* Add an error message to the DOM
*/
function showError () {
main.innerHTML = (
"<p>" +
"<strong>Sorry, there seems to be a problem. You can still view today's top stories using the link above.</strong>" +
"</p>"
);
}
Then I wrapped the entire promise chain inside a function, for the sake of readability:
/**
* Fetch the top five stories and add them the DOM
*/
function getStories () {
fetch(endpoint)
.then(getJSON)
.then(showStories)
.catch(showError);
}
And finally, called it to initialize the script:
// Fetch the top five stories and add them the DOM
getStories();
Multiple categories
I wanted to be able to show stories from multiple categories. To begin, I created a new variable to store an array of categories:
// The categories to fetch
var categories = ["food", "movies", "technology"];
I also modified my buildStory()
function. All I did was change the h2
to an h3,
since the categories will now be the second-level headings:
"<h3>" +
"<a href='" + story.url + "'>" + story.title + "</a>" +
"</h3>"
Then I created a function to build the HTML string for a single category. It creates an h2
for the category name itself, and then an article
for each of the first three stories under that category:
/**
* Build the HTML string for a single category
* @param {Array} stories An array of story objects
* @param {String} category The category to use
* @returns {String} An HTML string
*/
function buildCategory (stories, category) {
return (
"<h2>" + category + "</h2>" +
stories.slice(0, 3).map(buildStory).join("")
);
}
I passed this into another function, fetchCategory()
, to return a Fetch request for a given category in my categories
array. This time, I made a POST
request and passed the category to my endpoint as a JSON string:
/**
* Return a Fetch request for the given category
* @param {String} category The category to use
* @returns {Object} A promise object
*/
function fetchCategory (category) {
// Options for the request
var options = {
method: "POST",
body: JSON.stringify({ category: category })
};
// Return a promise for the request
return (
fetch(endpoint, options)
.then(getJSON)
.then(function (data) {
return buildCategory(data.results, category);
})
);
}
Next, I modified my showStories()
function to showCategories()
. It will now take the final HTML string—of all categories and articles—and add it to the DOM:
/**
* Join the category strings and add them to the DOM
* @param {Array} categories The array of category strings
*/
function showCategories (categories) {
main.innerHTML = categories.join("");
}
My showError()
function is the same as before.
Finally, I modified my getStories()
function to:
- Get an array of Fetch requests for the categories
- Create a single promise that resolves to an array of the results of the Fetch requests
- Join the resolved array and add it to the DOM
/**
* Add all stories from all categories to the DOM
*/
function getStories () {
// Create an array of Fetch requests for the categories
var requests = categories.map(fetchCategory);
// This will resolve once ALL the requests have resolved
var categoryStrings = Promise.all(requests);
// Join the resolved array and add it to the DOM
categoryStrings.then(showCategories).catch(showError);
}
Again, I initialized the script by calling this function:
// Add all stories from all categories to the DOM
getStories();
Sanitizing the third-party data
The script works, but there's a big security flaw. We still need to sanitize the third-party data to prevent XSS attacks.
Thankfully, this is easy to do. We just need to add in a helper function:
/**
* Sanitize and encode all HTML in a user-submitted string
* (c) 2018 Chris Ferdinandi, MIT License, https://gomakethings.com
* @param {String} str The user-submitted string
* @returns {String} The sanitized string
*/
function sanitizeHTML (str) {
var temp = document.createElement("div");
temp.textContent = str;
return temp.innerHTML;
}
And then wrap each piece of third-party data inside a call to this function:
/**
* Build the HTML string for a single story
* @param {Object} story The object returned by the API
* @returns {String} An HTML string
*/
function buildStory (story) {
// Sanitize the date here for readability
var date = sanitizeHTML(story.updated_date);
// Return the HTML string
return (
"<article>" +
"<header>" +
"<h3>" +
"<a href='" + sanitizeHTML(story.url) + "'>" + sanitizeHTML(story.title) + "</a>" +
"</h3>" +
"<p>" +
"<b>Last updated: </b>" +
"<time datetime='" + date + "'>" +
new Date(date).toLocaleString() +
"</time>" +
"</p>" +
"</header>" +
"<p>" + sanitizeHTML(story.abstract) + "</p>" +
"</article>"
);
}
If you want to build cool projects like this, I strongly encourage you to sign up for the next session of the Vanilla JS Academy! ❤️