Updated

Skip to Recipe

So you've decided to find a recipe online for Mexican tamales. You do a quick Google search and see a link with an image of some delicious-looking tamales, so you click on it. Next thing you know, you're reading the life story of a woman that lives in upstate New York about how she travelled with her four kids to Mexico where she ate the most delicious tamales and then spent her life trying to recover the recipe from blah blah blah. You're here for the recipe, right? Where is it?

Skip to Recipe is a simple Chrome Extension that will scan the webpages you visit for a recipe. If it finds one, it will automatically scroll it into view, leaving those pesky life stories in the dust. It's a very simple Chrome Extension with less than 100 lines of code, but I am proud of this solution because to me, at least, it feels clever and concise. Let me explain how I solved the problem.

Setting up the environment

So my first goal was to set up the project - the most challenging part about any Chrome Extension for me has been figuring out how to set up the Manifest v3, how to register background service workers, and how to inject code into the DOM - as well as understanding the execution context of the code in each of those circumstances.

The Manifest v3 is simple enough:

{
	"name": "Skip to Recipe",
	"description": "Skip all those annoying blogs and stories and go straight to the recipes every time.",
	"version": "0.1.1",
	"manifest_version": 3,
	"background": {
		"service_worker": "background.js"
	},
	"permissions": ["tabs", "scripting"],
	"host_permissions": ["<all_urls>"],
	"icons": {
		"16": "icons/16.png",
		"48": "icons/48.png",
		"64": "icons/64.png",
		"128": "icons/128.png"
	}
}

One interesting point here is the permissions - to ensure that I can read the DOM to check for recipes, I must have the tabs and scripting permissions. This is unfortunate - given that those permissions can be used maliciously, users of my extension will be prompted to allow my extension to read any data on the page. I know many people - myself included - are extremely wary of allowing arbitrary code to read my data on any website. Yet another reason to develop this extension myself and not try one of the solutions that I'm sure already exist for this problem.

The background.js service worker simply injects the main code into the DOM upon page load in each tab:

chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
	if (changeInfo.status == "complete" && !/^chrome/.test(tab.url)) {
		chrome.scripting.executeScript({
			target: {
				tabId,
			},
			files: ["findRecipe.js"],
		});
	}
});

So my code that exists inside findRecipe.js gets executed every time you load a page in your browser. Thankfully it's very brief and concise and won't cause any performance hit, unless you're counting nanoseconds.

Solving the problem

Now to solve the actual problem of finding a recipe on a page, I started examining the DOM of as many recipe websites that I could find. I noticed a commonality between most of these websites - the "ingredients" list section of the recipe nearly always had some sort of HTML attribute (such as id or a CSS class) with the word "ingredients" in it. Makes sense of course - ingredients are usually formatted a little differently than a normal list. So I ran with that idea, and it worked out fairly well.

So when findRecipe.js first runs, the following function is executed:

const scrollToRecipe = () => {
	const targetElement =
		findRecipeElementWithException() ||
		findRecipeElementWithAttribute("class", "ingredients") ||
		findRecipeElementWithAttribute("id", "ingredients") ||
		null;

	if (targetElement) {
		targetElement.scrollIntoView({
			block: "start",
			behavior: "smooth",
			alignToTop: true,
		});
	}
};

I tried to be as declarative with this code as it is concise - we first get a "targetElement" from either an exception, an element with a class that contains ingredients, or an element with an id that contains ingredients. Then, if we found one, we scroll it into view. I elected to use smooth scroll for UX reasons - it's helpful to know that the extension is working.

Implementation details

But what is an exception? Essentially, some websites don't actually have any HTML attributes for their ingredients section, or they do but it's located in the wrong place on the page. Out of the dozens of popular food blog sites I've tested, very few fall into this category, so I just elected to hard-code them and provide an alternative way to find the ingredients based on that website's DOM structure.

const cookingWebsiteExceptions = {
	"https://www.epicurious.com/": () =>
		findRecipeElementWithAttribute("data-testid", "ingredients"),
	"https://www.aspicyperspective.com/": () =>
		findRecipeElementWithAttribute("id", "recipe-container"),
	"https://www.the-girl-who-ate-everything.com/": () =>
		findRecipeElementWithAttribute("id", "wprm-recipe"),
};

const findRecipeElementWithException = () => {
	const href = window.location.href;

	for (let i in cookingWebsiteExceptions) {
		if (href.startsWith(i)) {
			return cookingWebsiteExceptions[i]();
		}
	}
	return null;
};

So the last part of my code to go over is the findRecipeElementWithAttribute function. This one is fairly straightforward:

const findRecipeElementWithAttribute = (attribute, value) => {
	const valueVariants = [
		value,
		value.toLowerCase(),
		value.toUpperCase(),
		value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(),
	];

	for (let i of valueVariants) {
		const element =
			document.querySelector(`[${attribute}*="${i}"]`) || null;
		if (element) {
			console.log(
				`Skip to Recipe: Recipe found at element with ${attribute}: ${element.getAttribute(
					attribute
				)}`
			);
			return element;
		}
	}

	return null;
};

First we will search based on 'ingredients', 'INGREDIENTS', and 'Ingredients' since some sites use different casing. The search is done by using a CSS attribute selector with an asterisk which will find attributes that contain at least one instance of the value. So if the element appears like this, the selector will still find it:

<div class="tasty-recipes-ingredients">
  ...
</div>

Conclusion

That's pretty much it! The only other challenge I faced was keeping the code here from clashing with existing function and variable names in the global scope, and that was easily solved by wrapping everything up in an immediately-invoked anonymous function.

I know it's not a whole lot of code, and it really only took me a couple hours to fully flesh out, but I'm still proud of it - primarily because it works well and just feels clean.

The extension is fully published and available for anyone to install in the Chrome Web Store.

Check out the full source in the project's public github repository.