]> git.ardis.lu - ardislu.dev/commitdiff
Refactor site to be a SPA
authorArdis Lu <ardisglu@gmail.com>
Sun, 27 Feb 2022 04:57:51 +0000 (20:57 -0800)
committerArdis Lu <ardisglu@gmail.com>
Sun, 27 Feb 2022 04:57:51 +0000 (20:57 -0800)
Using 404.html as a router is very bad for SEO. As a SPA I can also take advantage of in-memory state for better caching and storing metadata for posts.

404.html [deleted file]
README.md
errors/404.html [new file with mode: 0644]
index.html
print.css
script.js

diff --git a/404.html b/404.html
deleted file mode 100644 (file)
index 674a627..0000000
--- a/404.html
+++ /dev/null
@@ -1,77 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-
-<head>
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <meta name="description" content="Ardis Lu's personal blog.">
-  <link rel="icon" href="/favicon.ico">
-  <link rel="stylesheet" href="/style.css">
-  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/styles/default.min.css">
-  <link rel="stylesheet" href="/print.css" media="print">
-  <style media="print">
-    header {
-      display: none;
-    }
-  </style>
-  <title>ardislu.dev</title>
-</head>
-
-<body>
-  <header>
-    <a href="/">
-      <img src="/home.svg" width="24" height="24" alt="Home">
-    </a>
-    <a id="edit">
-      <img src="/pencil.svg" width="24" height="24" alt="Edit this article">
-    </a>
-  </header>
-  <main>
-    <article class="post"></article>
-  </main>
-  <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/3.0.4/marked.min.js"></script>
-  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/highlight.min.js"></script>
-  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/languages/powershell.min.js"></script>
-  <script>
-    const article = document.querySelector('article');
-    article.innerHTML = '<div class="skeleton-line"></div>'.repeat(20);
-
-    const mapping = {
-      TITLE: '# ',
-      SUBTITLE: '',
-      HEADING_1: '## ',
-      HEADING_2: '### ',
-      HEADING_3: '#### ',
-      NORMAL_TEXT: '',
-    };
-
-    const inject = (googleDoc) => {
-      document.title = googleDoc.title;
-      article.innerHTML = '';
-      let articleContent = '';
-      for (let content of googleDoc.body.content) {
-        const tag = mapping[content?.paragraph?.paragraphStyle?.namedStyleType];
-        const bullet = content?.paragraph?.bullet ? '- ' : '';
-        if (tag !== undefined) {
-          articleContent += `${bullet}${tag}${content.paragraph.elements[0].textRun.content}`;
-        }
-      }
-      article.innerHTML = marked(articleContent);
-    };
-
-    (async () => {
-      const proxyUrl = 'https://script.google.com/macros/s/AKfycbzMHxwypbwsccBq6RH8LDCAOpM02C4gbkljbcKHKFsoQTRE-f4XTIyswiaO0vshs-Kr/exec';
-      const documentId = window.location.pathname.split('/').pop();
-      const url = `${proxyUrl}?https://docs.googleapis.com/v1/documents/${documentId}`;
-      document.getElementById('edit').setAttribute('href', `https://docs.google.com/document/d/${documentId}/edit`);
-
-      await fetch(url)
-        .then((httpResponse) => httpResponse.json())
-        .then((googleDoc) => inject(googleDoc));
-
-      hljs.highlightAll();
-    })();
-  </script>
-</body>
-
-</html>
\ No newline at end of file
index 6d1c5c848cf4783101f97d74762222633565a2b1..fe3257f2dc9860b6b7473600ed26d8a37e42fce5 100644 (file)
--- a/README.md
+++ b/README.md
@@ -12,12 +12,16 @@ I'm using Google Sheets and Google Docs as the CMS for this website. A Google Sh
 
 Once I fetch an article from the Google Docs API, the article is converted into markdown then converted into HTML using [marked](https://marked.js.org).
 
-## 404.html as a router
+## Serving actual 404 errors for invalid routes while still being a SPA 
 
-Cloudflare Pages automatically redirects any `HTTP 404 Not Found` to `404.html` if it exists. I'm using `404.html` as a router for the blog posts dynamically fetched from the Google Sheet. Here's how to replicate Cloudflare Pages' treatment of `404.html` on a local development server:
+Cloudflare Pages automatically redirects any invalid routes in a directory to `404.html` if there is a `404.html` that exists in that directory. Since there's no `404.html` in the root, Cloudflare Pages will redirect all routes to `index.html`.
+
+I do a client-side redirect to `/errors/NotFound` for any routes that should have actually been a `404`, which Cloudflare Pages then serves a `404` with `/errors/404.html`.
+
+Here's how to replicate this behavior locally:
 
 1. Install Node.js (this is ONLY used for the web server; the website itself does not use Node.js)
 
-2. `npm i -g http-server`
+2. `npm i -g local-web-server`
 
-3. `http-server`
+3. `ws --spa index.html --rewrite '/errors/(.*) -> /errors/404.html'`
diff --git a/errors/404.html b/errors/404.html
new file mode 100644 (file)
index 0000000..5542acb
--- /dev/null
@@ -0,0 +1,53 @@
+<!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="Ardis Lu's personal blog.">
+  <link rel="icon" href="/favicon.ico">
+  <link rel="stylesheet" href="/style.css">
+  <link rel="stylesheet" href="/print.css" media="print">
+  <style>
+    main {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      inline-size: min(100% - 2rem, 1440px);
+      margin-inline: auto;
+    }
+  </style>
+  <title>404: Not Found</title>
+</head>
+
+<body>
+  <header>
+    <h1>404: Not Found</h1>
+  </header>
+
+  <main>
+    <div class="skeleton-line"></div>
+    <p hidden>The page you requested does not exist.</p>
+
+    <a href="/">
+      <img src="/home.svg" width="24" height="24" alt="Home">
+    </a>
+  </main>
+
+  <script>
+    const message = document.querySelector('p');
+    const params = new URLSearchParams(window.location.search);
+    const request = params.get('r');
+    const suggestion = params.get('s');
+
+    if (request !== null && suggestion !== null) {
+      message.innerHTML = `"${request}" does not exist. Did you mean <a href=/${suggestion}>${suggestion}</a>?`;
+    }
+
+    document.querySelector('div').remove();
+    message.removeAttribute('hidden');
+  </script>
+</body>
+
+</html>
\ No newline at end of file
index 3b6a436c70e177e51ba7e52f4693bf4fca31bd6d..0d911b45e5dc9ab252dbc56a073982690cfa03b8 100644 (file)
@@ -6,19 +6,57 @@
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <meta name="description" content="Ardis Lu's personal blog.">
   <link rel="icon" href="/favicon.ico">
+  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/styles/default.min.css">
   <link rel="stylesheet" href="/style.css">
   <link rel="stylesheet" href="/print.css" media="print">
   <title>ardislu.dev</title>
 </head>
 
 <body>
-  <header>
-    <h1>ardislu.dev</h1>
-    <a href="https://github.com/ardislu/ardislu.dev">
-      <img src="/github.svg" width="24" height="24" alt="GitHub">
-    </a>
-  </header>
-  <main class="card-container"></main>
+  <noscript>
+    This site uses JavaScript to fetch blog posts and implement client-side routing.
+    View this site's source code on <a href="https://github.com/ardislu/ardislu.dev">GitHub</a>.
+  </noscript>
+
+  <template id="home-header">
+    <header>
+      <h1>ardislu.dev</h1>
+      <a href="https://github.com/ardislu/ardislu.dev">
+        <img src="/github.svg" width="24" height="24" alt="GitHub">
+      </a>
+    </header>
+  </template>
+
+  <template id="home-loader">
+    <main class="card-container"></main>
+  </template>
+
+  <template id="home-main">
+    <main class="card-container"></main>
+  </template>
+
+  <template id="post-header">
+    <header class="post-header">
+      <a href="/">
+        <img src="/home.svg" width="24" height="24" alt="Home">
+      </a>
+      <a id="edit">
+        <img src="/pencil.svg" width="24" height="24" alt="Edit this article">
+      </a>
+    </header>
+  </template>
+
+  <template id="post-loader">
+    <main></main>
+  </template>
+
+  <template id="post-main">
+    <main></main>
+  </template>
+
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/3.0.4/marked.min.js"></script>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/highlight.min.js"></script>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/languages/powershell.min.js"></script>
   <script src="/script.js"></script>
 </body>
 
index b5763b0c04972aaf8ecd4047d2d939e85ae7abc9..fd58b8e83d95ce155443b1cd1ef0dd9b3686a37d 100644 (file)
--- a/print.css
+++ b/print.css
@@ -46,3 +46,7 @@ pre {
   break-inside: avoid;
   border: none;
 }
+
+.post-header {
+  display: none;
+}
index d3a0a100e49346759d27c56679556a45b7d1a4ed..83e5d96b90e58c187ec3996a6d765db96532fc8a 100644 (file)
--- a/script.js
+++ b/script.js
@@ -2,56 +2,183 @@ if ('serviceWorker' in navigator) {
   navigator.serviceWorker.register('/sw.js');
 }
 
-const main = document.querySelector('main');
-const halfRemHeight = parseFloat(getComputedStyle(document.documentElement).fontSize) / 2;
-
-/* Generic element builders */
-const createSkeletonElement = () => {
-  const el = document.createElement('article');
-  el.classList.add('card');
-  el.innerHTML = '<div class="skeleton-line"></div>'.repeat(6);
-  return el;
-};
-
-const createArticleElement = (article) => {
-  const el = document.createElement('article');
-  el.classList.add('card');
-  el.innerHTML = `<h2>${article[0]}</h2><p>${article[1]}</p><p><a href="/posts/${article[2]}">${article[3]}</a></p>`;
-  return el;
-};
-
-const setRowSpan = (card) => {
-  let contentHeight = 0;
-  for (let child of card.children) {
-    contentHeight += child.clientHeight;
-  }
-  card.style.gridRowEnd = `span ${Math.ceil(contentHeight / halfRemHeight)}` // Fit the card's row span to the height of the card's content
-}
+/* Global data store for posts */
+const posts = new Map();
 
-/* Inject placeholder skeleton loaders */
-for (let i = 0; i < 12; i++) {
-  const a = createSkeletonElement();
-  main.insertAdjacentElement('beforeend', a);
-};
+/* Define components from the initial HTML templates */
+const components = new Map();
+document.querySelectorAll('template').forEach(t => {
+  components.set(t.id, t.content.firstElementChild);
+  t.remove();
+});
+const homeHeader = components.get('home-header');
+const homeLoader = components.get('home-loader');
+const homeMain = components.get('home-main');
+const postHeader = components.get('post-header');
+const postLoader = components.get('post-loader');
+const postMain = components.get('post-main');
 
-/* Callback to inject content from API */
-const inject = (resp) => {
-  main.innerHTML = '';
-  for (let a of resp) {
-    const card = createArticleElement(a);
-    main.insertAdjacentElement('beforeend', card); // Must insert the element first because contentHeight is dynamic on card width
-    setRowSpan(card);
-  }
-};
+/* Hydrate the loaders */
+homeLoader.insertAdjacentHTML('beforeend',
+  `<article class="card">
+    ${'<div class="skeleton-line"></div>'.repeat(6)}
+  </article>`.repeat(12));
+
+postLoader.insertAdjacentHTML('beforeend',
+  `<article class="post">
+    ${'<div class="skeleton-line"></div>'.repeat(25)}
+  </article>`);
 
-/* Query Google Sheet for the card content */
-(async () => {
+/* Call the CMS controller to get all post metadata. Note: must call fetchPostContent to get content. */
+async function fetchPostMetadata() {
   const proxyUrl = 'https://script.google.com/macros/s/AKfycbzMHxwypbwsccBq6RH8LDCAOpM02C4gbkljbcKHKFsoQTRE-f4XTIyswiaO0vshs-Kr/exec';
   const controllerId = '1pfGF8yBu3D0GPTezygLuzu3Cif8SkjhtG98nL-czlhc';
-  const range = 'B3:E';
+  const range = 'B3:I';
   const url = `${proxyUrl}?https://sheets.googleapis.com/v4/spreadsheets/${controllerId}/values/${range}`;
 
   await fetch(url)
-    .then((httpResponse) => httpResponse.json())
-    .then((googleSheetsRange) => inject(googleSheetsRange.values));
-})();
+    .then(r => r.json())
+    .then(googleSheet => {
+      posts.clear();
+      for (const article of googleSheet.values) {
+        posts.set(article[4], {
+          title: article[0],
+          description: article[1],
+          id: article[2],
+          linkText: article[3],
+          createdDate: article[5],
+          updatedDate: article[6],
+          tags: article[7]
+        });
+      }
+    });
+}
+
+/* Get content for the given path */
+async function fetchPostContent(path) {
+  const post = posts.get(path);
+  const proxyUrl = 'https://script.google.com/macros/s/AKfycbzMHxwypbwsccBq6RH8LDCAOpM02C4gbkljbcKHKFsoQTRE-f4XTIyswiaO0vshs-Kr/exec';
+  const url = `${proxyUrl}?https://docs.googleapis.com/v1/documents/${post.id}`;
+
+  const mapping = {
+    TITLE: '# ',
+    SUBTITLE: '',
+    HEADING_1: '## ',
+    HEADING_2: '### ',
+    HEADING_3: '#### ',
+    NORMAL_TEXT: '',
+  };
+
+  await fetch(url)
+    .then(r => r.json())
+    .then(googleDoc => {
+      let articleContent = '';
+      for (const content of googleDoc.body.content) {
+        const tag = mapping[content?.paragraph?.paragraphStyle?.namedStyleType];
+        const bullet = content?.paragraph?.bullet ? '- ' : '';
+        if (tag !== undefined) {
+          articleContent += `${bullet}${tag}${content.paragraph.elements[0].textRun.content}`;
+        }
+      }
+      post.content = articleContent;
+    });
+}
+
+/* Client-side routing */
+async function showPage(path) {
+  document.body.innerHTML = '';
+
+  // Determine which placeholders to show
+  if (path === 'home' || path === '') {
+    document.body.appendChild(homeHeader);
+    document.body.appendChild(homeLoader);
+  }
+  else {
+    document.body.appendChild(postHeader);
+    document.body.appendChild(postLoader);
+  }
+
+  // Load all post metadata if it hasn't been loaded yet
+  if (posts.size === 0) {
+    await fetchPostMetadata();
+  }
+
+  if (path === 'home' || path === '') {
+    homeMain.innerHTML = '';
+    for (const [path, post] of posts) {
+      homeMain.insertAdjacentHTML('beforeend',
+        `<article class="card">
+          <h2>${post.title}</h2><p>${post.description}</p><p><a href="/${path}">${post.linkText}</a></p>
+        </article>`);
+    }
+    homeLoader.remove();
+    document.body.appendChild(homeMain);
+  }
+  else if (posts.has(path)) {
+    const post = posts.get(path);
+
+    document.title = post.title;
+    postHeader.querySelector('#edit').href = `https://docs.google.com/document/d/${post.id}/edit`;
+
+    if (post.content === undefined) {
+      await fetchPostContent(path);
+    }
+
+    postMain.innerHTML = '';
+    postMain.insertAdjacentHTML('beforeend',
+      `<article class="post">
+        ${marked(post.content)}
+      </article>`);
+    postLoader.remove();
+    document.body.appendChild(postMain);
+    hljs.highlightAll();
+  }
+  else {
+    // MUST redirect to a page where the web server serves an actual 404 error (i.e. not just 
+    // index.html again) otherwise this triggers an infinite loop
+    window.location.href = `${window.location.protocol}//${window.location.host}/errors/NotFound`;
+  }
+}
+
+function route(href) {
+  const url = new URL(href);
+
+  // If the new page is equal to the current page, don't do anything
+  if (window.location.href === url.href) {
+    return;
+  }
+  // If the new page is on a different host, redirect as expected
+  if (window.location.host !== url.host) {
+    window.location.href = href;
+    return;
+  }
+
+  window.history.pushState({}, '', url);
+  showPage(url.pathname.substring(1));
+}
+
+/* Set event handlers for client-side routing */
+window.addEventListener('popstate', _ => showPage(window.location.pathname.substring(1)));
+document.addEventListener('click', e => {
+  e.preventDefault();
+  const anchor = e.target.closest('a');
+  if (!anchor) {
+    return;
+  }
+  route(anchor.href);
+});
+
+// Show the current URL (to support direct linking aka deep links)
+showPage(window.location.pathname.substring(1));
+
+/* Set grid-row-end for each card on homeMain resize. Required to implement masonry layout. */
+new ResizeObserver(_ => {
+  const halfRemHeight = parseFloat(getComputedStyle(document.documentElement).fontSize) / 2;
+  for (let card of homeMain.children) {
+    let contentHeight = 0;
+    for (let child of card.children) {
+      contentHeight += child.clientHeight;
+    }
+    card.style.gridRowEnd = `span ${Math.ceil(contentHeight / halfRemHeight)}`;
+  }
+}).observe(homeMain);
This page took 0.06098 seconds and 4 git commands to generate.