Automating Sitemap updates for a marketplace

Welcome to a comprehensive guide on automating sitemap updates for dynamic websites.

Technologies utilized

  • Firebase Config (for credentials)

  • Firebase Firestore (for sitemap data & function triggers)

  • Firebase Functions (for executing logic)

  • Octokit API (for GitHub)

The Workflow

  1. Determining the specific events that should trigger the start of Sitemap generation.

  2. Load the data for a specific sitemap.xml file by retrieving the product data from Firestore.

  3. We will create the content of the sitemap-products-xml file using the provided data.

  4. Obtain the latest commit SHA from the main branch.

  5. Creating a new branch for the later PR.

  6. Generating the updated sitemap content; Applying it to the respective file; Then commiting the changes.

  7. Creating a PR for the website repository.

async function updateSitemapProducts(): Promise<void> {
  const fileName = "sitemap-templates.xml";
  const owner = "github_user_name";
  const repo = "repository_name";
  const branch = "main"; 
  const pathToFile = `public/${fileName}`;
  const prBranchName = `seo_sitemap_products_update`;
  try {
    const newBranchTimestamp = new Date().toISOString().replace(/:/g, "-");
    const newBranch = `${prBranchName}_${newBranchTimestamp}`;

    // 1. Step
    const productsData = await getProductsData();

    // 2. Step
    const sitemapContent = generateSitemapContent(templatesData);

    // 3. Step
    const lastCommitSha = await getLatestCommitSha(owner, repo, branch);

    // 4. Step
    await createNewBranch(owner, repo, newBranch, lastCommitSha);

    // 5. Step
    await commitChanges(owner, repo, newBranch, pathToFile, sitemapContent, `Update ${fileName}`);

    // 6. Step
    await createPullRequest(owner, repo, newBranch, branch, `Update ${fileName}`);
  } catch (error) {
    // Handle error
  }
}

1. Setting Up Firebase Firestore Triggers

Begin with creating Firebase Functions to trigger sitemap updates. This will respond to changes in Firestore.

The method for updating the sitemap.xml depends on website requirements. Learn about the frequency methods, benefits, and disadvantages here.

I will use Firestore Triggers to regenerate the sitemap.xml file whenever a product is created or deleted from the products collection.

export const onCreateProductUpdateSitemapProducts = functions
.firestore.document(`/products/{productId}`)
  .onCreate(async (snapshot) => {
    await updateSitemapProducts();
  });

export const onDeleteProductUpdateSitemapProducts = functions
  .firestore.document(`/products/{productId}`)
  .onDelete(async (snapshot) => {
    await updateSitemapProducts();
  });

2. Retrieving Data from Firestore

Query Firestore to get the latest data for the templates. We expect that a product has at least an id and a field about when it was last updated. Here’s the example type.

type Product = {
    id: string;
    lastUpdated: Date;
};

Next we will just load the documents of a specific collection from Firestore.

async function getProductsData(): Promise<Product[]> {
    const productsSnapshot = await firestore.collection("products").get();
    const products = productsSnapshot.docs.map((doc) => productConverter.fromFirestore(doc););
    return profiles;
}

3. Generating Sitemap Content

Construct the XML content for the sitemap-templates.xml file using the fetched data. This is an example sitemap.xml structure, read more here about the sitemap.xml structure and available attributes.

function generateSitemapContent(products: Product[]): string {
    let xmlContent = '<?xml version="1.0" encoding="UTF-8"?>\n';
    xmlContent += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
    products.forEach((product) => {
          const dateFormatted = product.lastUpdates.toISOString().split("T")[0];
          xmlContent += "  <url>\n";
          xmlContent += "    <loc>www.website.com/products/" + product.id +"</loc>\n";
          xmlContent += "    <lastmod>" + dateFormatted + "</lastmod>\n";
          xmlContent += "    <changefreq>daily</changefreq>\n";
          xmlContent += "    <priority>0.8</priority>\n";
          xmlContent += "  </url>\n";
    });
    xmlContent += "</urlset>";
    return xmlContent;
}

4–6. GitHub Integration

The following utils file can be used to handle steps 4 to 6, which includes loading the last commit SHA, creating a new branch, making the changes, committing and creating PR, using Octokit.

Install the Octokit dependency for your Firebase Functions project.

npm install @octokit/rest

Don't forget to get your GitHub token and set it through Firebase configurations.

firebase functions:config:set github.token=TOKEN_PLACEHOLDER
import { Octokit } from "@octokit/rest";
import * as functions from "firebase-functions";

// GitHub API configs
const githubConfig = functions.config().github;
const githubToken = githubConfig.token;

const octokit = new Octokit({ auth: githubToken });

/**
 * Creates a new branch on GitHub.
 * @param {string} owner The owner of the repository.
 * @param {string} repo The repository name.
 * @param {string} newBranch The name of the new branch.
 * @param {string} sha The SHA of the latest commit on the main branch.
 * @return {Promise<void>} The result of the function.
 */
export async function createNewBranch(owner: string, repo: string, newBranch: string, sha: string): Promise<void> {
  await octokit.git.createRef({
    owner,
    repo,
    ref: `refs/heads/${newBranch}`,
    sha,
  });
}

/**
 * Commits the changes to the new branch.
 * @param {string} owner The owner of the repository.
 * @param {string} repo The repository name.
 * @param {string} branch The name of the new branch.
 * @param {string} filePath The path to the file to update.
 * @param {string} content The content to update the file with.
 * @param {string} message The commit message.
 * @return {Promise<void>} The result of the function.
 */
export async function commitChanges(owner: string, repo: string, branch: string, filePath: string, content: string, message: string): Promise<void> {
  const sha = await getFileSha(owner, repo, filePath);
  await octokit.repos.createOrUpdateFileContents({
    owner,
    repo,
    path: filePath,
    message,
    content: Buffer.from(content).toString("base64"),
    sha,
    branch,
  });
}

/**
 * Creates a pull request.
 * @param {string} owner The owner of the repository.
 * @param {string} repo The repository name.
 * @param {string} head The name of the new branch.
 * @param {string} base The name of the base branch.
 * @param {string} title The title of the pull request.
 * @return {Promise<void>} The result of the function.
 */
export async function createPullRequest(owner: string, repo: string, head: string, base: string, title: string): Promise<void> {
  await octokit.pulls.create({
    owner,
    repo,
    title,
    head,
    base,
    body: "Automatically generated pull request.",
  });
}

/**
 * Gets the latest commit SHA from the main branch.
 * @param {string} owner The owner of the repository.
 * @param {string} repo The repository name.
 * @param {string} branch The name of the branch.
 * @return {Promise<string>} The latest commit SHA.
 */
export async function getLatestCommitSha(owner: string, repo: string, branch: string): Promise<string> {
  const { data } = await octokit.repos.getBranch({
    owner,
    repo,
    branch,
  });

  return data.commit.sha;
}

/**
 * Gets the SHA of a file.
 * @param {string} owner The owner of the repository.
 * @param {string} repo The repository name.
 * @param {string} path The path to the file.
 * @return {Promise<string | undefined>} The SHA of the file.
 */
export async function getFileSha(owner: string, repo: string, path: string): Promise<string | undefined> {
  try {
    const response = await octokit.repos.getContent({
      owner,
      repo,
      path,
    });

    // Check if the response is for a single file and has a SHA property
    if ("sha" in response.data && !Array.isArray(response.data)) {
      return response.data.sha;
    } else {
      // Handle the scenario where it's not a single file (e.g., a directory or does not exist)
      return undefined;
    }
  } catch (error) {
    console.error("Error fetching file SHA:", error);
    // If the file does not exist, you might get a 404 error, handle it as needed
    return undefined;
  }
}

Conclusion

By implementing this new flow, any time a product is added or removed, the sitemap-products.xml file will automatically update. This greatly improves SEO maintenance and minimizes the need for manual effort to keep the sitemap up-to-date. The integration of Firebase and GitHub demonstrates the power of automated workflows in website management.

0
Subscribe to my newsletter

Read articles from Maximilian Keppeler directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Maximilian Keppeler
Maximilian Keppeler

Indie Web and Android Dev and Senior Android Dev @ Lufthansa Group ✨ Crafting a portfolio of apps, websites, & Open-Source libraries. 💫