From ecdfa1803a844946ed44b0eb0e05354eb5efe28d Mon Sep 17 00:00:00 2001 From: Ardis Lu Date: Sat, 26 Feb 2022 20:57:51 -0800 Subject: [PATCH] Refactor site to be a SPA 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 | 77 ----------------- README.md | 12 ++- errors/404.html | 53 ++++++++++++ index.html | 52 ++++++++++-- print.css | 4 + script.js | 215 ++++++++++++++++++++++++++++++++++++++---------- 6 files changed, 281 insertions(+), 132 deletions(-) delete mode 100644 404.html create mode 100644 errors/404.html diff --git a/404.html b/404.html deleted file mode 100644 index 674a627..0000000 --- a/404.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - ardislu.dev - - - -
- - Home - - - Edit this article - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index 6d1c5c8..fe3257f 100644 --- 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 index 0000000..5542acb --- /dev/null +++ b/errors/404.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + + 404: Not Found + + + +
+

404: Not Found

+
+ +
+
+ + + + Home + +
+ + + + + \ No newline at end of file diff --git a/index.html b/index.html index 3b6a436..0d911b4 100644 --- a/index.html +++ b/index.html @@ -6,19 +6,57 @@ + ardislu.dev -
-

ardislu.dev

- - GitHub - -
-
+ + + + + + + + + + + + + + + + + diff --git a/print.css b/print.css index b5763b0..fd58b8e 100644 --- a/print.css +++ b/print.css @@ -46,3 +46,7 @@ pre { break-inside: avoid; border: none; } + +.post-header { + display: none; +} diff --git a/script.js b/script.js index d3a0a10..83e5d96 100644 --- 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; +/* Global data store for posts */ +const posts = new Map(); -/* Generic element builders */ -const createSkeletonElement = () => { - const el = document.createElement('article'); - el.classList.add('card'); - el.innerHTML = '
'.repeat(6); - return el; -}; +/* 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'); -const createArticleElement = (article) => { - const el = document.createElement('article'); - el.classList.add('card'); - el.innerHTML = `

${article[0]}

${article[1]}

${article[3]}

`; - return el; -}; +/* Hydrate the loaders */ +homeLoader.insertAdjacentHTML('beforeend', + `
+ ${'
'.repeat(6)} +
`.repeat(12)); -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 -} +postLoader.insertAdjacentHTML('beforeend', + `
+ ${'
'.repeat(25)} +
`); -/* Inject placeholder skeleton loaders */ -for (let i = 0; i < 12; i++) { - const a = createSkeletonElement(); - main.insertAdjacentElement('beforeend', a); -}; - -/* 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); - } -}; - -/* 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', + `
+

${post.title}

${post.description}

${post.linkText}

+
`); + } + 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', + `
+ ${marked(post.content)} +
`); + 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); -- 2.47.3