+++ /dev/null
-<!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
<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>
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);