initial feed implementation
This commit is contained in:
		| @@ -13,6 +13,9 @@ | ||||
|     export const description = ''; | ||||
|     export const draft = false; | ||||
|     export let toc = null; | ||||
|  | ||||
|     export let prev = null; | ||||
|     export let next = null; | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| @@ -46,7 +49,6 @@ | ||||
|         grid-column: 2 / 3; | ||||
|         margin-bottom: 2rem; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
|  | ||||
|     hr { | ||||
| @@ -101,18 +103,23 @@ | ||||
|     <hr> | ||||
|  | ||||
|     <div class="footer"> | ||||
|         <a href="#"> | ||||
|         {#if prev} | ||||
|             <a href="/{prev}" data-sveltekit-preload-data="hover"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> | ||||
|                     <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /> | ||||
|                 </svg> | ||||
|                 Previous | ||||
|             </a> | ||||
|         {/if} | ||||
|  | ||||
|         <a href="#"> | ||||
|         {#if next} | ||||
|             <!-- we use margin-left rather than justify-content so it works regardless of whether the "previous" link exists --> | ||||
|             <a href="/{next}" style="margin-left: auto;" data-sveltekit-preload-data="hover"> | ||||
|                 Next | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> | ||||
|                   <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /> | ||||
|                 </svg> | ||||
|             </a> | ||||
|         {/if} | ||||
|     </div> | ||||
| </div> | ||||
| @@ -23,7 +23,7 @@ | ||||
|     } | ||||
|  | ||||
|     .sidenote:before { | ||||
|         content: counter(sidenote) " "; | ||||
|         content: var(--sidenote-index, counter(sidenote)) " "; | ||||
|         /* absolute positioning puts it at the top-left corner of the sidenote, overlapping with the content | ||||
|         (because the sidenote is floated it counts as a positioned parent, I think) */ | ||||
|         position: absolute; | ||||
| @@ -46,7 +46,7 @@ | ||||
|  | ||||
|         .sidenote { | ||||
|             --gap: 2rem; | ||||
|             --sidenote-width: min(14rem, calc(50vw - var(--gap) - var(--content-width) / 2)); | ||||
|             --sidenote-width: min(16rem, calc(50vw - var(--gap) - 1rem - var(--content-width) / 2)); | ||||
|             width: var(--sidenote-width); | ||||
|             hyphens: auto; | ||||
|             position: relative; | ||||
|   | ||||
| @@ -11,7 +11,6 @@ | ||||
|     let currentSubheadingSlug = null; | ||||
|  | ||||
|     function setCurrentHeading() { | ||||
|         const start = performance.now(); | ||||
|         for (const h of headings) { | ||||
|             const yPos = h.getBoundingClientRect().y; | ||||
|             if (yPos > (window.innerHeight / 3)) { | ||||
| @@ -25,8 +24,6 @@ | ||||
|                 currentSubheadingSlug = h.id | ||||
|             } | ||||
|         } | ||||
|         const end = performance.now(); | ||||
|         console.log(`Elapsed: ${end - start}`); | ||||
|     } | ||||
|  | ||||
|     onMount (() => { | ||||
|   | ||||
							
								
								
									
										94
									
								
								src/lib/xml.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/lib/xml.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | ||||
| // const Node = { | ||||
| //     addChild(child) { | ||||
| //         this.children.push(child); | ||||
| //         return child; | ||||
| //     } | ||||
| // } | ||||
|  | ||||
|  | ||||
| export function tag(name, attrs, children) { | ||||
|     return { | ||||
|         type: 'tag', | ||||
|         tag: name, | ||||
|         attrs: attrs || {}, | ||||
|         children: children || [], | ||||
|  | ||||
|         addTag(name, attrs, children) { | ||||
|             const child = tag(name, attrs, children); | ||||
|             this.children.push(child); | ||||
|             return child; | ||||
|         }, | ||||
|     }; | ||||
| } | ||||
|  | ||||
|  | ||||
| export function text(content) { | ||||
|     return { | ||||
|         type: 'text', | ||||
|         text: content, | ||||
|     }; | ||||
| } | ||||
|  | ||||
|  | ||||
| export function serialize(node, depth) { | ||||
|     if (!depth) { | ||||
|         depth = 0; | ||||
|     } | ||||
|  | ||||
|     const indent = ' '.repeat(depth * 4); | ||||
|     let fragments = []; | ||||
|  | ||||
|     // version tag, if this is the top level | ||||
|     if (depth === 0) { | ||||
|         fragments.push('<?xml version="1.0" encoding="UTF-8"?>\n') | ||||
|     } | ||||
|  | ||||
|     fragments.push(`${indent}<${node.tag}`); | ||||
|  | ||||
|     // this happens if there are multiple text nodes within the same parent | ||||
|     if (node.type === 'text') { | ||||
|         return `${indent}${escape(node.text)}`; | ||||
|     } | ||||
|  | ||||
|     if (node.children === undefined) { | ||||
|         console.log(node); | ||||
|     } | ||||
|  | ||||
|     // opening tag <element attr="value"> | ||||
|     for (const attr in node.attrs) { | ||||
|         fragments.push(` ${attr}="${node.attrs[attr]}"`); | ||||
|     } | ||||
|     if (node.children.length === 0) { | ||||
|         fragments.push(' />'); | ||||
|         return fragments.join(''); | ||||
|     } | ||||
|     fragments.push('>'); | ||||
|  | ||||
|     // if the only child is a single text node, skip recursion and just dump contents directly | ||||
|     if (node.children.length === 1 && node.children[0].type === 'text') { | ||||
|         const text = escape(node.children[0].text); | ||||
|         fragments.push(text); | ||||
|     } | ||||
|     // otherwise, start a new line for each child node, then recurse | ||||
|     else { | ||||
|         for (const child of node.children) { | ||||
|             fragments.push('\n'); | ||||
|             fragments.push(serialize(child, depth + 1)); | ||||
|         } | ||||
|         // no need to verify that there were children, we already did that | ||||
|         fragments.push(`\n${indent}`); | ||||
|     } | ||||
|  | ||||
|     fragments.push(`</${node.tag}>`); | ||||
|  | ||||
|     return fragments.join(''); | ||||
| } | ||||
|  | ||||
|  | ||||
| function escape(text) { | ||||
|     // we aren't going to bother with escaping attributes, so we won't worry about quotes | ||||
|     return text | ||||
|         .replaceAll('&', '&') | ||||
|         .replaceAll('<', '>') | ||||
|         .replaceAll('>', '<'); | ||||
| } | ||||
| @@ -1,19 +1,48 @@ | ||||
| import { visit } from 'unist-util-visit'; | ||||
| import { toString } from 'mdast-util-to-string'; | ||||
|  | ||||
| import fs from 'node:fs'; | ||||
|  | ||||
| // build table of contents and inject into frontmatter | ||||
| export function localRemark() { | ||||
|     return (tree, vfile) => { | ||||
|         let toc = []; | ||||
|         let description = null; | ||||
|          | ||||
|         visit(tree, 'heading', node => { | ||||
|         visit(tree, ['heading', 'paragraph'], node => { | ||||
|             // build table of contents and inject into frontmatter | ||||
|             if (node.type === 'heading') { | ||||
|                 toc.push({ | ||||
|                     text: toString(node), | ||||
|                     depth: node.depth, | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             // inject description (first 25 words of the first paragraph) | ||||
|             if (node.type === 'paragraph' && description === null) { | ||||
|                 description = summarize(node); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         vfile.data.fm.toc = toc; | ||||
|         vfile.data.fm.description = description; | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| // convert paragraph to single string after stripping everything between html tags | ||||
| function summarize(par) { | ||||
|     let newChildren = []; | ||||
|     let push = true; | ||||
|     for (const child of par.children) { | ||||
|         if (child.type === 'html') { | ||||
|             push = !push; | ||||
|             continue; | ||||
|         } | ||||
|         if (push) { | ||||
|             newChildren.push(child); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     return toString({type: 'paragraph', children: newChildren}); | ||||
| } | ||||
|   | ||||
| @@ -12,6 +12,7 @@ export async function load({ url, params, data }) { | ||||
|     } | ||||
|     catch (err) { | ||||
|         // throw error(404, `Not found: ${url.pathname}`); | ||||
|         console.log(err); | ||||
|         throw err; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import { writable } from 'svelte/store'; | ||||
| import { dev } from '$app/environment'; | ||||
| const posts = import.meta.globEager('./*.svx'); | ||||
|  | ||||
| @@ -16,6 +15,19 @@ for (const path in posts) { | ||||
|     postData.push(posts[path].metadata); | ||||
| } | ||||
|  | ||||
|  | ||||
| let ids = new Set(); | ||||
| for (const postMeta of postData) { | ||||
|     if (postMeta.uuid === undefined) { | ||||
|         throw(`Missing UUID for post: ${postMeta.title}`); | ||||
|     } | ||||
|     if (ids.has(postMeta.uuid)) { | ||||
|         throw(`Duplicate UUID in post: ${postMeta.title}`); | ||||
|     } | ||||
|     ids.add(postMeta.uuid); | ||||
| } | ||||
|  | ||||
|  | ||||
| postData.sort((a, b) => { | ||||
|     // sorting in reverse, so we flip the intuitive order | ||||
|     if (a.date > b.date) return -1; | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| title: Exposing Docker Containers to your LAN | ||||
| description: If, for some strange reason, you should want to do such a thing. | ||||
| date: 2022-03-21 | ||||
| uuid: 81715fb3-990e-487e-9662-fed7b7d02943 | ||||
| --- | ||||
| <script> | ||||
|     import Sidenote from '$lib/Sidenote.svelte'; | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| title: The Hitchiker's Guide to Mesh VPNs | ||||
| description: The golden age of VPNery is upon us. | ||||
| date: 2022-03-17 | ||||
| uuid: fc6930ef-979c-4851-bc5a-c0e1b1698061 | ||||
| --- | ||||
| <script> | ||||
|     import Sidenote from '$lib/Sidenote.svelte'; | ||||
|   | ||||
| @@ -4,6 +4,7 @@ description: Can we replace passwords with something more user-friendly? | ||||
| date: 2021-04-30 | ||||
| draft: true | ||||
| dropcap: false | ||||
| uuid: 696020b3-1513-42a8-b346-634d40f0e9d9 | ||||
| --- | ||||
| <script> | ||||
|     import Sidenote from '$lib/Sidenote.svelte'; | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| title: 'Languages: High and Low' | ||||
| description: How high is up? | ||||
| date: 2022-08-19 | ||||
| uuid: 89ae4194-7785-4fac-a841-8bcf5a5a3a2e | ||||
| draft: true | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| title: Sidenotes | ||||
| description: An entirely-too-detailed dive into how I implemented sidenotes for this blog. | ||||
| date: 2023-08-14 | ||||
| uuid: c514c46e-92f3-4078-a76b-e1dafd5f7e07 | ||||
| --- | ||||
| <script> | ||||
|     import Sidenote from '$lib/Sidenote.svelte'; | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| title: Let's Design A Simpler SocketIO | ||||
| date: 2021-10-16 | ||||
| description: SocketIO is packed with features. But do we really need all of them all the time? | ||||
| uuid: 95cde7e7-9293-4fab-a0b4-fc6ab7da08c8 | ||||
| draft: true | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| title: Sufficiently Advanced Technology Is Often Distinguishable From Magic | ||||
| description: I see what Arthur C. Clarke was getting at, but I don't think I agree. | ||||
| date: 2022-05-14 | ||||
| uuid: 84636766-eb78-4060-a98d-593d8d5b55c9 | ||||
| draft: true | ||||
| --- | ||||
| <script> | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| title: Thoughts on Vue vs Svelte | ||||
| description: They're more similar than they are different, but they say the most bitter enemies are those who have the fewest differences. | ||||
| date: 2023-06-29 | ||||
| uuid: 8280f0e0-6bf5-43a2-9eac-b8c2508cca29 | ||||
| --- | ||||
| <script> | ||||
|     import Sidenote from '$lib/Sidenote.svelte'; | ||||
|   | ||||
							
								
								
									
										49
									
								
								src/routes/feed/+server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/routes/feed/+server.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| import { tag, text, serialize } from '$lib/xml.js'; | ||||
| import { postData } from '../_posts/all.js'; | ||||
|  | ||||
|  | ||||
| export function GET() { | ||||
|     return new Response(renderFeed(), { | ||||
|         headers: {'Content-Type': 'text/xml'} | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| function renderFeed() { | ||||
|     const feed = tag('feed', {xmlns: 'http://www.w3.org/2005/Atom'}); | ||||
|  | ||||
|     feed.addTag('id', {}, [text('https://blog.jfmonty2.com')]) | ||||
|     feed.addTag('title', {}, [text("Joe's Blog")]); | ||||
|     feed.addTag('link', {href: 'https://blog.jfmonty2.com/'}); | ||||
|  | ||||
|     const lastUpdate = iso(postData[0].updated || postData[0].date); | ||||
|     feed.addTag('updated', {}, [text(lastUpdate)]); | ||||
|  | ||||
|     const author = feed.addTag('author'); | ||||
|     author.addTag('name', {}, [text('Joseph Montanaro')]); | ||||
|  | ||||
|     for (const post of postData) { | ||||
|         const entry = feed.addTag('entry'); | ||||
|         entry.addTag('title', {}, [text(post.title)]); | ||||
|         entry.addTag('link', {rel: 'alternate', href: `https://blog.jfmonty2.com/${post.slug}`}); | ||||
|         entry.addTag('id', {}, [text(post.uuid)]); | ||||
|  | ||||
|         const publishedDate = iso(post.date); | ||||
|         entry.addTag('published', {}, [text(publishedDate)]) | ||||
|         const updatedDate = iso(post.updated || post.date); | ||||
|         entry.addTag('updated', {}, [text(updatedDate)]); | ||||
|  | ||||
|         entry.addTag('content', {type: 'html'}, [text(renderDescription(post))]); | ||||
|     } | ||||
|     return serialize(feed); | ||||
| } | ||||
|  | ||||
|  | ||||
| function renderDescription(post) { | ||||
|     return `<p>${post.description} <a href="https://blog.jfmonty2.com/${post.slug}">Read more</a></p>`; | ||||
| } | ||||
|  | ||||
|  | ||||
| function iso(datetimeStr) { | ||||
|     return new Date(datetimeStr).toISOString(); | ||||
| } | ||||
| @@ -6,14 +6,13 @@ | ||||
| <style> | ||||
|     #posts { | ||||
|         /*text-align:  center;*/ | ||||
|         max-width:  24rem; | ||||
|         margin-left:  auto; | ||||
|         margin-right:  auto; | ||||
|         max-width:  var(--content-width); | ||||
|         margin:  0 auto; | ||||
|     } | ||||
|  | ||||
|     .post { | ||||
|         border-bottom: 2px solid #eee; | ||||
|         margin-top: 1rem; | ||||
|     hr { | ||||
|         margin: 2rem 0; | ||||
|         border-color: #eee; | ||||
|     } | ||||
|  | ||||
|     .post-date { | ||||
| @@ -38,9 +37,14 @@ | ||||
|         text-decoration: underline; | ||||
|     } | ||||
|  | ||||
|     h3 { | ||||
|         display: inline; | ||||
|         margin:  0; | ||||
|     h2 { | ||||
|         font-size: 1.25rem; | ||||
|         margin-top: 0.5rem; | ||||
|         margin-bottom: 0.75rem; | ||||
|     } | ||||
|  | ||||
|     h2 a { | ||||
|         color: currentcolor; | ||||
|     } | ||||
| </style> | ||||
|  | ||||
| @@ -50,18 +54,22 @@ | ||||
|  | ||||
| <div id="posts"> | ||||
|     <h1 style:text-align="center">All Posts</h1> | ||||
|     {#each postData as post} | ||||
|     {#each postData as post, idx} | ||||
|         <div class="post"> | ||||
|             <div class="post-date">{new Date(post.date).toISOString().split('T')[0]}</div> | ||||
|             <div> | ||||
|             <h2> | ||||
|                 <a data-sveltekit-preload-data="hover" class="post-link" href="/{post.slug}"> | ||||
|                     <h3>{post.title}<h3> | ||||
|                     {post.title} | ||||
|                 </a> | ||||
|                 {#if post.draft} | ||||
|                     <span class="draft-notice">Draft</span> | ||||
|                 {/if} | ||||
|             </div> | ||||
|             </h2> | ||||
|             <p>{post.description}</p> | ||||
|         </div> | ||||
|  | ||||
|         {#if idx < postData.length - 1} | ||||
|         <hr> | ||||
|         {/if} | ||||
|     {/each} | ||||
| </div> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user