2
\$\begingroup\$

I've built a very simple personal website, everything is in a single file because it is very straightforward, but I think the JS script could be better structured. All it does is fetch some repos from the GitHub API and displays them in a horrible format.

This is it (index.html):

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="The successful warrior is the average man, with laser-like focus."
    />
    <title>Mauricio Robayo</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;700&display=swap"
      rel="stylesheet"
    />
    <style>
      :root {
        font-size: 14px;
      }
      * {
        margin: 0;
        color: gainsboro;
      }
      body {
        font-family: "IBM Plex Mono", monospace;
        background-color: darkblue;
        padding: 0.5rem;
      }
      h1,
      h2,
      h4 {
        font-size: 1rem;
      }
      h1 {
        margin-bottom: 2rem;
      }
      h2 {
        font-weight: 400;
      }
      h4 {
        margin-top: 1rem;
      }
      ul {
        list-style: none;
        list-style-position: inside;
        padding-left: 0rem;
      }
      .error,
      .error a {
        color: red;
      }
      section {
        margin: 2rem 0;
      }
      .repo-loader {
        margin-right: 0.5rem;
      }
      .meta {
        color: darkgray;
      }
      .repo-header > *:not(:last-child) {
        margin-right: 1rem;
      }
      @media screen and (min-width: 768px) {
        :root {
          font-size: 16px;
        }
        body {
          padding: 1rem;
        }
        .repo-header {
          display: flex;
          align-items: flex-end;
        }
      }
    </style>
  </head>
  <body>
    <h1>Mauricio Robayo</h1>
    <ul>
      <li>
        <a
          href="&#109;&#97;&#105;&#108;&#116;&#111;&#58;&#114;&#102;&#109;&#97;&#106;&#111;&#64;&#103;&#109;&#97;&#105;&#108;&#46;&#99;&#111;&#109;"
          >&#109;&#97;&#105;&#108;&#116;&#111;&#58;&#114;&#102;&#109;&#97;&#106;&#111;&#64;&#103;&#109;&#97;&#105;&#108;&#46;&#99;&#111;&#109;</a
        >
      </li>
      <li>
        <a href="https://www.linkedin.com/in/mauriciorobayo"
          >https://www.linkedin.com/in/mauriciorobayo</a
        >
      </li>
    </ul>
    <section id="recent-projects"></section>
    <section id="starred-projects"></section>
    <footer>
      <p>No frameworks were harmed in the making of this site.</p>
      <p>Analytics and bloatware free.</p>
      <p>
        Updated:
        <a href="https://youtu.be/dQw4w9WgXcQ?autoplay=1">27 July 1987</a>.
      </p>
    </footer>
    <script>
      loadProjects(document.getElementById("recent-projects"), "updated", 3, [
        "language",
        "updated_at",
      ]);
      loadProjects(document.getElementById("starred-projects"), "stars", 3, [
        "language",
        "stargazers_count",
      ]);

      function loadProjects(projectsContainer, sort, limit, meta) {
        const url = `https://api.github.com/search/repositories?q=user:MauricioRobayo&sort=${sort}&per_page=${limit}`;
        const projectsTitle = document.createElement("h2");
        const projectsBody = document.createElement("div");
        const loader = document.createElement("span");
        const link = document.createElement("a");
        const cacheStatus = document.createElement("p");
        const cacheExpirationTime = document.createElement("p");
        const cacheKey = `projects-${sort}`;

        projectsBody.classList.add("projects-body");
        loader.classList.add("repo-loader");
        link.href = url;
        link.textContent = url;

        const loaderSymbols = ["\\", "|", "/", "—"];
        let i = 0;
        loader.textContent = loaderSymbols[i % loaderSymbols.length];
        const interval = setInterval(() => {
          i++;
          loader.textContent = loaderSymbols[i % loaderSymbols.length];
        }, 500);

        const cache = JSON.parse(localStorage.getItem(cacheKey));

        projectsTitle.append(
          loader,
          "Fetching ",
          link,
          cacheStatus,
          cacheExpirationTime
        );
        projectsContainer.append(projectsTitle, projectsBody);

        if (cache?.expirationTime > Date.now()) {
          cacheStatus.textContent = "localStorage cache hit";
          cacheExpirationTime.textContent = `Expires: ${cache.expirationTime}`;
          loadContent({ repos: cache.repos, projectsBody, loader, meta });
          clearInterval(interval);
        } else {
          cacheStatus.textContent = "localStorage cache miss";
          fetch(url, {
            headers: {
              accept: "application/vnd.github.v3+json",
            },
          })
            .then((response) => {
              if (!response.ok) {
                throw new Error(
                  `${response.status ? response.status : ""} ${
                    response.statusText ? response.statusText : ""
                  }`
                );
              }

              const rateLimitElements = Array.from(response.headers)
                .filter(([key]) => key.toLowerCase().startsWith("x-ratelimit"))
                .map((header) => {
                  const headerElement = document.createElement("p");
                  headerElement.textContent = header.join(": ");
                  return headerElement;
                });
              projectsBody.append(...rateLimitElements);

              return response.json();
            })
            .then(({ items: repos }) => {
              localStorage.setItem(
                cacheKey,
                JSON.stringify({
                  repos,
                  expirationTime: Date.now() + 60 * 60 * 1000,
                })
              );
              loadContent({ repos, projectsBody, loader, meta });
            })
            .catch((err) => {
              loader.textContent = "✗";
              projectsContainer.append(err);
              projectsContainer.classList.add("error");
            })
            .finally(() => {
              clearInterval(interval);
            });
        }
      }

      function loadContent({ repos, projectsBody, loader, meta }) {
        const reposContainers = repos.map(
          ({
            name,
            description,
            html_url,
            language,
            stargazers_count,
            updated_at,
          }) => {
            const repoContainer = document.createElement("div");
            const metaFields = { language, stargazers_count, updated_at };
            repoContainer.innerHTML = `
        <div class="repo-header">
          <h4><a href="${html_url}">${name}</a></h4>
          ${buildMeta(
            Object.fromEntries(
              Object.entries(metaFields).filter(([key]) => meta.includes(key))
            )
          )}
          </div>
          <div class="repo-body">
            <p>${description ? description : ""}</p>
          </div>
        `;
            return repoContainer;
          }
        );
        loader.textContent = "✔";
        projectsBody.append(...reposContainers);
      }

      function buildMeta(meta) {
        const rtf1 = new Intl.RelativeTimeFormat("en", { style: "narrow" });

        return Object.entries(meta)
          .map(([key, value]) => {
            if (key === "updated_at") {
              const updatedDate = new Date(value);
              const millisecondsDiff = updatedDate.getTime() - Date.now();
              const daysDiff = Math.floor(
                millisecondsDiff / 1000 / 60 / 60 / 24
              );
              const relativeTime = rtf1.format(daysDiff, "day");
              return `<span class="meta">updated ${relativeTime}</span>`;
            }
            if (key === "stargazers_count") {
              return `<span class="meta">${value} stars</span>`;
            }
            return `<span class="meta">${value}</span>`;
          })
          .join("");
      }
    </script>
  </body>
</html>

I would appreciate any input on the JS script to make it more readable, clean, and maintainable, although any feedback regarding the CSS and the HTML (better structure, semantic markup, accessibility, etc) would also be great.

\$\endgroup\$
2
\$\begingroup\$

First of all, kudos for making a single simple file!

It was relatively straightforward to read as well, though I think mostly because of the well known api and that there isn't much code. Formatting was nice as well.

Here's what I think could improve it:

  • Since you're already using const, promise, arrow functions, etc, I think you could benefit from using async/await as well.
  • loadProjects does basically everything. When I then saw "loadContent" I was expecting to see something similar, but in fact it was just rendering. Then I got to "buildMeta" which was also rendering, except it was not outputing to DOM. The names confuse me, and it's probably because there is no real attempt at abstraction/separation of concerns. More on that below.
  • loadProjects does many things. I would look to separate these:
    • rendering / dom
    • loading state + interval
    • fetching + transformation of result
    • caching
    • error handling
  • loadContent inserts itself to the dom. There's really no need. It's easier to read and understand if the dom generating code just returns dom (pure functions)
  • Do you really need to display rate limiting errors?
\$\endgroup\$
1
\$\begingroup\$

Good things

The code appears to make good use of ES-6 features like template literals, arrow functions, etc., as well as functional techniques like .map(), .filter(), etc. Indentation appears to be consistent.

I agree with Magnus - the ES8 features async/await could help simplify the callbacks. There are just a few suggestions I have to add - see the next section.

Suggestions

Pre-fix increment

The interval function to update the loader could be simplified from:

const interval = setInterval(() => {
    i++;
    loader.textContent = loaderSymbols[i % loaderSymbols.length];
}, 500);

to this - using prefix increment

const interval = setInterval(() => {
    loader.textContent = loaderSymbols[++i % loaderSymbols.length];
}, 500);

Micro-optimization: spread operator

When creating an array from the response headers:

const rateLimitElements = Array.from(response.headers) 

The spread syntax could be used since the headers are iterable - which requires one less function call and is slightly shorter to write:

const rateLimitElements = [...response.headers] 

Ternary simplification

When checking for the description:

<p>${description ? description : ""}</p>

This could be simplified with the Logical OR operator ||:

<p>${description || ""}</p>
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.