Compare commits

..

3 Commits

Author SHA1 Message Date
b4a1097845 buncha stuff 2023-08-16 21:45:48 -07:00
adc582116b wip sidenotes post 2023-08-15 11:48:07 -07:00
7d5c696fa7 start working on sidenotes post 2023-08-15 11:10:22 -07:00
65 changed files with 6676 additions and 5148 deletions

7
.gitignore vendored
View File

@ -3,10 +3,3 @@ node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
**/_test.*
/scratch

2
.npmrc
View File

@ -1,2 +0,0 @@
engine-strict=true
resolution-mode=highest

View File

@ -1,6 +1,6 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte);
## Creating a project
@ -8,12 +8,14 @@ If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
npm init svelte@next
# create a new project in my-app
npm create svelte@latest my-app
npm init svelte@next my-app
```
> Note: the `@next` is temporary
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
@ -27,12 +29,10 @@ npm run dev -- --open
## Building
To create a production version of your app:
Before creating a production version of your app, install an [adapter](https://kit.svelte.dev/docs#adapters) for your target environment. Then:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
> You can preview the built app with `npm run preview`, regardless of whether you installed an adapter. This should _not_ be used to serve your app in production.

11
jsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"$lib": ["src/lib"],
"$lib/*": ["src/lib/*"]
}
},
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

8942
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,18 @@
{
"name": "blog.jfmonty2.com",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.20.4",
"hast-util-to-html": "^9.0.0",
"hast-util-to-text": "^4.0.0",
"mdast-util-to-string": "^4.0.0",
"mdsvex": "^0.11.0",
"sass": "^1.69.5",
"svelte": "^4.0.5",
"unist-util-find": "^3.0.0",
"unist-util-visit": "^5.0.0",
"vite": "^4.4.2"
},
"type": "module"
"name": "blog",
"version": "0.0.1",
"scripts": {
"dev": "svelte-kit dev",
"build": "svelte-kit build",
"preview": "svelte-kit preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^1.0.0-next.21",
"@sveltejs/kit": "next",
"mdsvex": "^0.9.8",
"node-sass": "^6.0.1",
"svelte": "^3.42.6",
"svelte-preprocess": "^4.9.8"
},
"type": "module"
}

View File

@ -3,11 +3,13 @@
<head>
<meta charset="utf-8" />
<link rel="preload" href="/Tajawal-Regular.woff2" as="font" type="font/woff2" />
<link rel="alternate" type="application/atom+xml" href="/feed">
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
<link rel="preload" href="/Baskerville-Regular.woff2" as="font" type="font/woff2" />
<link rel="icon" href="/favicon.png" />
<link rel="stylesheet" href="/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%svelte.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
<body>
<div id="svelte">%svelte.body%</div>
</body>
</html>

View File

@ -1,9 +0,0 @@
<script>
let classes = '';
export {classes as class};
</script>
<p>Hello world!</p>
<pre class={classes}>
<slot></slot>
</pre>

View File

@ -2,10 +2,10 @@
// Usage: <Dropcap word="Lorem">ipsum dolor sit amet...</Dropcap>
export let word;
const initial = word.slice(0, 1).toUpperCase();
const initial = word.slice(0, 1);
const remainder = word.slice(1);
// a few letters are narrower at the top, so we need to shift the remainder to compensate
// a few letters are narrower at the top, so we need more of a shift
const shiftValues = {
A: '-0.45em',
L: '-0.3em',
@ -16,23 +16,16 @@
</script>
<style>
@font-face {
font-family: 'Baskerville';
font-style: normal;
font-weight: 400;
src: url(/Baskerville-Regular.woff2) format('woff2');
font-display: block;
}
.drop-cap {
display: block;
float: left;
margin-right: 0.1em;
color: var(--accent-color);
font-size: calc(var(--content-size) * 1.5 * 2);
line-height: 0.8;
font-family: 'Baskerville';
text-transform: uppercase;
color: #8c0606;
/* box-sizing: border-box;*/
font-size: calc(var(--content-size) * var(--content-line-height) * 1.75);
float: left;
font-family: 'Baskerville';
line-height: 0.8;
margin-right: 0.1em;
display: block;
}
.first-word {
@ -41,12 +34,9 @@
}
</style>
<svelte:head>
<link rel="preload" href="/Baskerville-Regular.woff2" as="font" type="font/woff2">
</svelte:head>
<span class="drop-cap">{initial}</span>
{#if remainder.length}
<p>
<span class="drop-cap">{initial}</span>
<span class="first-word" style:--shift={shift}>{remainder}</span>
{/if}
<slot></slot>
</p>

View File

@ -1,68 +0,0 @@
<script>
export let level;
export let id = '';
const tag = `h${level}`;
</script>
<style lang="scss">
.h {
position: relative;
}
// shift the anchor link to hang off the left side of the content when there's room
.anchor-wrapper {
// slightly overlap the span with the heading so that it doesn't
// lose its hover state as the cursor moves between them
position: absolute;
padding-right: 0.5em;
left: -1.25em;
@media(max-width: 58rem) {
position: revert;
}
}
a {
// works better to set the size here for line-height reasons
font-size: 0.9em;
// give the anchor link a faded appearance by default
color: hsl(0deg, 0%, 29%);
opacity: 40%;
transition: opacity 150ms, color 150ms;
&:hover {
border-bottom: 0.05em solid currentcolor;
}
}
// emphasize anchor link when heading is hovered or when clicked (the latter for mobile)
.h:hover a, .anchor-wrapper:hover a, .h a:active {
color: var(--accent-color);
opacity: 100%;
}
svg {
// undo the reset that makes images block
display: inline;
width: 1em;
// tiny tweak for optical alignment
transform: translateY(2px);
}
</style>
<svelte:element this={tag} {id} class="h">
<span>
<slot></slot>
</span>
<!-- Icon from https://heroicons.com/ -->
<span class="anchor-wrapper">
<a href="#{id}" >
<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.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
</a>
</span>
</svelte:element>

View File

@ -13,13 +13,12 @@
</script>
<script>
export let href;
export let rel = '';
export let href; // we don't care about other attributes
</script>
{#if href.startsWith('/') || host(href) === $page.host}
<a data-sveltekit-preload-data="hover" {href} {rel}>
<a sveltekit:prefetch {href}>
<slot></slot>
</a>
{:else}

View File

@ -1,150 +1,33 @@
<script context="module">
import '$styles/prose.scss';
import '$styles/code.scss';
import { onMount } from 'svelte';
import { formatDate } from './datefmt.js';
import { makeSlug } from '$lib/utils.js';
import { makeSlug } from '$lib/slug.js';
import Toc from './Toc.svelte';
import Link from './Link.svelte';
export { Link as a };
</script>
<script>
export let title, date;
export let description = '';
export const description = '';
export const draft = false;
export let toc = null;
export let slug;
export let prev = null;
export let next = null;
</script>
<style lang="scss">
.page {
/* 3-column grid: left gutter, center content, and right gutter */
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, var(--content-width)) minmax(0, 1fr);
/* a bit of breathing room for narrow screens */
padding: 0 var(--content-padding);
}
/* container for the table of contents */
.left-gutter {
grid-column: 1 / 2;
justify-self: end;
}
.title {
grid-column: 2 / 3;
}
<style>
.subtitle {
font-size: 0.9em;
font-style: italic;
margin-top: -0.75rem;
}
.post {
grid-column: 2 / 3;
}
hr {
grid-column: 2 / 3;
width: 100%;
border-top: 1px solid hsl(0 0% 75%);
border-bottom: none;
margin: 2.5rem 0;
}
.footer {
grid-column: 2 / 3;
margin-bottom: 2.5rem;
display: flex;
& a {
display: flex;
align-items: center;
gap: 0.45em;
font-size: 1.25rem;
color: var(--content-color-faded);
text-decoration: underline;
text-underline-offset: 0.25em;
text-decoration-color: transparent;
transition: 150ms;
&:hover {
text-decoration-color: currentColor;
text-decoration: underline;
}
}
& svg {
width: 1em;
transition: 150ms;
}
& .prev:hover svg {
transform: translateX(-50%);
}
& .next:hover svg {
transform: translateX(50%);
}
margin-top: -0.5rem;
}
</style>
<svelte:head>
<title>{title} | Joe's Blog</title>
<meta property="og:title" content="{title} | Joe's Blog">
<meta property="og:type" content="article">
<meta property="og:url" content="https://blog.jfmonty2.com/{slug}">
<meta property="og:description" content={description}>
<meta property="og:site_name" content="Joe's Blog">
<!-- Put this here for now, until I can get custom components working for codeblocks -->
<link rel="preload" href="/Hack-Regular.woff2" as="font" type="font/woff2">
<title>{title}</title>
<link rel="stylesheet" href="/prism-dracula.css" />
</svelte:head>
<div class="page prose">
<div class="title">
<h1 id="{makeSlug(title)}">{title}</h1>
<p class="subtitle">{formatDate(date)}</p>
</div>
<div class="left-gutter">
{#if toc && toc.length !== 0}
<Toc items={toc} />
{/if}
</div>
<div class="post">
<slot></slot>
</div>
<hr>
<div class="footer">
{#if prev}
<a href="/{prev}" class="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">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
Previous
</a>
{/if}
{#if next}
<!-- we use margin-left rather than justify-content so it works regardless of whether the "previous" link exists -->
<a href="/{next}" class="next" style="margin-left: auto;" data-sveltekit-preload-data="hover">
<span style:vertical-align={'center'}>Next</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
{/if}
</div>
<div id="post">
<h1 id="{makeSlug(title)}">{title}</h1>
<p class="subtitle">{formatDate(date)}</p>
<slot></slot>
</div>

View File

@ -1,219 +1,174 @@
<style lang="scss">
// minimum desirable sidenote width is 15rem, so breakpoint is
// content-width + 2(gap) + 2(15rem) + 2(scrollbar buffer)
$sidenote-breakpoint: 89.5rem;
// this has to be global because otherwise we can't target the body
/* always applicable */
:global(body) {
counter-reset: sidenote;
}
.counter.anchor {
.counter {
counter-increment: sidenote;
color: #444;
margin-left: 0.065rem;
font-size: 0.75em;
position: relative;
bottom: 0.375rem;
color: var(--accent-color);
margin-left: 0.05rem;
@media(max-width: $sidenote-breakpoint) {
&:hover {
color: var(--content-color);
cursor: pointer;
}
// only top-level anchors get brackets
&:not(.nested)::before {
content: '[';
}
&:not(.nested)::after {
content: ']';
}
&:after {
font-size: 0.75em;
position: relative;
bottom: 0.3rem;
color: #8c0606;
}
}
.counter.floating {
position: absolute;
transform: translateX(calc(-100% - 0.4em));
color: var(--accent-color);
.sidenote {
color: #555;
font-size: 0.8rem;
&:before {
content: 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;
/* translate moves it out to the left (and just a touch up to mimic the superscript efect)
-100% refers to the width of the element, so it pushes it out further if necessary (i.e. two digits instead of one) */
transform: translate(calc(-100% - 0.2rem), -0.15rem);
font-size: 0.75rem;
color: #8c0606;
}
}
// hidden checkbox that tracks the state of the mobile sidenote
.sidenote-toggle {
display: none;
}
.sidenote {
// anchor the counter, which is absolutely positioned
position: relative;
color: #555;
font-size: var(--content-size-sm);
line-height: 1.25;
hyphens: auto;
/* desktop display */
@media(min-width: 70em) {
.counter:after {
content: counter(sidenote);
}
// desktop display, this can't coexist with mobile styling
@media(min-width: $sidenote-breakpoint) {
// max sidenote width is 20rem, if the window is too small then it's
// the width of the gutter, minus the gap between sidenote and gutter,
// minus an extra 1.5rem to account for the scrollbar on the right
--gap: 2.5rem;
--gutter-width: calc(50vw - var(--content-width) / 2);
--sidenote-width: min(
24rem,
calc(var(--gutter-width) - var(--gap) - 1.5rem)
);
.sidenote {
--gap: 2rem;
--sidenote-width: min(14rem, calc(50vw - var(--gap) - var(--content-width) / 2));
width: var(--sidenote-width);
hyphens: auto;
position: relative;
float: right;
clear: right;
margin-right: calc(-1 * var(--sidenote-width) - var(--gap));
margin-bottom: 0.75rem;
margin-right: calc(0rem - var(--sidenote-width) - var(--gap)); // gives us 2rem of space between content and sidenote
margin-bottom: 0.7rem;
}
@media(max-width: $sidenote-breakpoint) {
position: fixed;
left: 0;
right: 0;
bottom: 0;
// since headings have relative position, any that come after
// the current sidenote in the DOM get stacked on top by default
z-index: 10;
// give us a horizontal buffer for the counter and dismiss button
--padding-x: calc(var(--content-padding) + 1.5rem);
padding: 1rem var(--padding-x);
background-color: white;
box-shadow: 0 -2px 4px -1px rgba(0, 0, 0, 0.06), 0 -2px 12px -2px rgba(0, 0, 0, 0.1);
// show the sidenote only when the corresponding checkbox is checked
transform: translateY(calc(100% + 2rem));
transition: transform 125ms;
// when moving from shown -> hidden, ease-in
transition-timing-function: ease-in;
.sidenote-toggle:checked + & {
transform: translateY(0);
// when moving hidden -> shown, ease-out
transition-timing-function: ease-out;
// the active sidenote should be on top of any other sidenotes as well
// (this isn't critical unless you have JS disabled, but it's still annoying)
z-index: 20;
}
}
}
.sidenote-content {
max-width: var(--content-width);
margin: 0 auto;
&.nested {
margin-right: 0;
margin-top: 0.75rem;
margin-bottom: 0;
}
}
.dismiss {
display: block;
width: max-content;
margin: 0.5rem auto 0;
border-radius: 100%;
background: white;
border: 1px solid hsl(0deg, 0%, 75%);
box-shadow: 1px 1px 4px -1px rgba(0, 0, 0, 0.1);
padding: 0.25rem;
color: hsl(0deg, 0%, 50%);
&:hover, &:active {
color: var(--accent-color);
border: 1px solid var(--accent-color);
}
display: none;
@media(max-width: $sidenote-breakpoint) {
display: block;
}
cursor: pointer;
& label {
cursor: pointer;
}
& svg {
height: 1.5rem;
}
}
// nesting still needs work
/* @media(min-width: $sidenote-breakpoint) {
.nested.sidenote {
margin-right: 0;
margin-top: 0.7rem;
margin-bottom: 0;
}
} */
.dismiss {
display: none;
}
}
/* mobile display */
@media (max-width: 70em) {
.counter:after {
content: "[" counter(sidenote) "]";
}
.counter:hover:after {
color: #000;
cursor: pointer;
}
.sidenote {
box-sizing: border-box;
position: fixed;
z-index: 1;
left: 0;
bottom: 0;
width: 100vw;
padding-top: 1rem;
padding-bottom: 1rem;
--pad: max(1rem, calc(50vw - var(--content-width) / 2));
padding-left: var(--pad);
padding-right: var(--pad);
background-color: #fff;
box-shadow: 0 -2px 4px -1px rgba(0, 0, 0, 0.06), 0 -2px 12px -2px rgba(0, 0, 0, 0.1);
display: none;
}
.sidenote-toggle:checked + .sidenote {
display: block;
}
.dismiss {
position: absolute;
right: 1.5rem;
top: -0.2rem;
font-size: 1.25rem;
color: #8c0606;
cursor: pointer;
&:hover {
transform: scale(1.1);
font-weight: 800;
}
}
}
// /* slight tweaks for in between state */
// @media (min-width: 52.5em) and (max-width: 70em) {
// .sidenote {
// padding-left: calc(50vw - 19rem);
// }
// }
// @media (max-width: 52.5em) {
// .sidenote {
// padding-left: 2rem;
// }
// }
</style>
<script context="module">
import { writable } from 'svelte/store';
let activeSidenote = writable(null);
var activeToggle = null;
</script>
<script>
import { onMount } from 'svelte';
export let count;
let noteBody;
let nested = false;
onMount(() => {
// check to see if the parent node is also a sidenote, if so move this one to the end
let parentContent = noteBody.parentElement.closest('div.sidenote-content');
if (parentContent) {
// extract just the content of the nested note, ditch the rest (i.e. the button)
const noteContent = noteBody.firstChild;
let parentNote = noteBody.parentElement.closest('span.sidenote');
if (parentNote) {
noteBody.remove();
parentContent.appendChild(noteContent);
parentNote.appendChild(noteBody);
nested = true;
}
});
const id = Math.random().toString().slice(2);
let toggle;
activeSidenote.subscribe(activeCount => {
// if we were the active toggle, but are no longer, hide
if (toggle?.checked && activeCount !== count) {
toggle.checked = false;
}
})
function toggleState() {
// if we are the active sidenote, deactivate us (upating the store will trigger subscription)
if ($activeSidenote === count) {
$activeSidenote = null;
if (activeToggle === toggle) {
activeToggle = null;
}
else if (activeToggle !== null) {
activeToggle.checked = false;
activeToggle = toggle;
}
// otherwise, we are becoming active
else {
$activeSidenote = count;
activeToggle = toggle;
}
}
</script>
<label for={count} class="counter anchor" class:nested>{count}</label>
<input id={count} bind:this={toggle} on:click={toggleState} type="checkbox" class="sidenote-toggle" />
<!-- outer element so that on mobile it can extend the whole width of the viewport -->
<div class="sidenote" bind:this={noteBody}>
<!-- inner element so that content can be centered -->
<div class="sidenote-content" class:nested>
<span class="counter floating">{count}</span>
<slot></slot>
</div>
<button class="dismiss">
<label for={count}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</label>
</button>
</div>
<label for={id} on:click={toggleState} class="counter"></label>
<input {id} bind:this={toggle} type="checkbox" class="sidenote-toggle" />
<span class="sidenote" class:nested bind:this={noteBody}>
<label class="dismiss" for={id} on:click={toggleState}>&times;</label>
<slot></slot>
</span>

View File

@ -1,166 +0,0 @@
<script>
import { onMount } from 'svelte';
import { makeSlug } from '$lib/utils.js';
export let items;
items.forEach(i => i.slug = makeSlug(i.text));
let headings = [];
let currentHeadingSlug = null;
let currentSubheadingSlug = null;
function setCurrentHeading() {
for (const h of headings) {
const yPos = h.getBoundingClientRect().y;
if (yPos > (window.innerHeight / 3)) {
break;
}
if (h.tagName === 'H2') {
currentHeadingSlug = h.id;
currentSubheadingSlug = null;
}
if (h.tagName === 'H3') {
currentSubheadingSlug = h.id
}
}
}
function ellipsize(text) {
return text;
// not sure about this, decide on it later
// if (text.length > 40) {
// text = text.slice(0, 40);
// // looks weird when we have an ellipsis following a space
// if (text.slice(-1) === ' ') {
// text = text.slice(0, -1);
// }
// return text + '…';
// }
// return text;
}
onMount (() => {
// These shouldn't change over the life of the page, so we can cache them
headings = Array.from(document.querySelectorAll('h2[id], h3[id]'));
setCurrentHeading();
});
</script>
<svelte:window on:scroll={setCurrentHeading} />
<style lang="scss">
#toc {
position: sticky;
top: 1.5rem;
margin-left: 1rem;
margin-right: 4rem;
max-width: 18rem;
color: var(--content-color-faded);
// minimum desirable TOC width is 8rem
// add 4rem for margins, giving total gutter width of 12.5rem
// multiply by 2 since there are two equally-sized gutters, then add content-width (52.5rem)
@media(max-width: 77.5rem) {
display: none;
}
}
// margin-left is to match the padding on the top-level list items,
// but here it needs to be margin so that the border is also shifted
h5 {
font-variant: petite-caps;
font-weight: 500;
max-width: fit-content;
margin-top: 0;
margin-bottom: 0.25em;
padding-bottom: 0.25em;
border-bottom: 1px solid currentcolor;
// make the border stretch beyond the text just a bit, because I like the effect
padding-right: 1.5rem;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
position: relative;
margin-top: 0.45em;
font-size: var(--content-size-sm);
// make sure that one item wrapped across multiple lines doesn't just looke like multiple items
line-height: 1.1;
&.depth-2 {
align-items: stretch;
margin-bottom: 0.2rem;
}
&.depth-3 {
align-items: center;
margin-bottom: 0.05rem;
}
&.current, &:hover {
color: var(--content-color);
}
}
.marker {
position: absolute;
left: -0.6rem;
.current &, li:hover & {
background-color: var(--accent-color);
}
&.bar {
width: 0.125rem;
height: 100%;
}
&.dot {
width: 0.2rem;
height: 0.2rem;
border-radius: 50%;
// vertically center within its containing block
top: 0;
bottom: 0;
margin: auto 0;
}
}
// default link styling messes everything up again
a {
color: inherit;
text-decoration: none;
}
</style>
<div id="toc">
<h5>
<span class="heading">Contents</span>
</h5>
<ul>
{#each items as item}
{#if item.depth === 2}
<li class="depth-2" class:current={item.slug === currentHeadingSlug} style:align-items="stretch">
<span class="marker bar"></span>
<a href="#{item.slug}">{ellipsize(item.text)}</a>
</li>
{:else if item.depth === 3}
<li class="depth-3" class:current={item.slug === currentSubheadingSlug} style:align-items="center" style:margin-left="0.75em">
<span class="marker dot"></span>
<a href="#{item.slug}">{ellipsize(item.text)}</a>
</li>
{/if}
{/each}
</ul>
</div>

View File

@ -0,0 +1,20 @@
<script>
import Step from './Step.svelte';
import {onMount} from 'svelte';
let frame;
onMount(() => {
frame.setAttribute('srcdoc', frame.innerHTML);
})
</script>
<iframe bind:this={frame}>
<html>
<head></head>
<body>
<Step />
<p>Goodbye world!</p>
</body>
</html>
</iframe>

View File

@ -0,0 +1,7 @@
<script>
let count = 0;
</script>
<p>hello world!</p>
<button on:click={() => count++}>Increment</button>
<p>The count is: {count}</p>

50
src/lib/slug.js Normal file
View File

@ -0,0 +1,50 @@
const nonAlphaNum = /[^A-Za-z0-9\-]/g;
const space = /\s/g
export function makeSlug(text) {
return text
.toLowerCase()
.replace(space, '-')
.replace(nonAlphaNum, '')
}
function apply(node, types, fn) {
if (typeof types === 'string') {
types = new Set([types]);
}
else if (!(types instanceof Set)) {
types = new Set(types)
console.log(types)
}
if (types.has(node.type)) {
fn(node);
}
if ('children' in node) {
for (let child of node.children) {
apply(child, types, fn);
}
}
}
function getTextContent(node) {
let segments = [];
apply(node, 'text', textNode => {
// skip all-whitespace strings
if (textNode.value.match(/^\s+$/)) return;
segments.push(textNode.value.trim());
});
return segments.join(' ');
}
export default function slug() {
return (tree) => {
apply(tree, 'element', e => {
if (e.tagName.match(/h[1-6]/)) {
let text = getTextContent(e);
e.properties.id = makeSlug(text);
}
})
}
}

View File

@ -1,8 +0,0 @@
const nonAlphaNum = /[^A-Za-z0-9\-]/g;
const space = /\s+/g;
export function makeSlug(text) {
return text
.toLowerCase()
.replace(space, '-')
.replace(nonAlphaNum, '');
}

View File

@ -1,94 +0,0 @@
// 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}

View File

@ -1,116 +0,0 @@
import { visit, CONTINUE, EXIT, SKIP, } from 'unist-util-visit';
import { find } from 'unist-util-find';
import { toText } from 'hast-util-to-text';
import { makeSlug } from '../lib/utils.js';
import {writeFileSync} from 'node:fs';
import {toHtml} from 'hast-util-to-html';
export function localRehype() {
return (tree, vfile) => {
const needsDropcap = vfile.data.fm.dropcap !== false
let dropcapAdded = false;
let sidenotesCount = 0;
let moduleScript;
let imports = new Set();
if (needsDropcap) {
imports.add("import Dropcap from '$lib/Dropcap.svelte';");
}
visit(tree, node => {
// add slugs to headings
if (isHeading(node)) {
processHeading(node);
imports.add("import Heading from '$lib/Heading.svelte';");
return SKIP;
}
// mdsvex adds a <script context="module"> so we just hijack that for our own purposes
if (isModuleScript(node)) {
moduleScript = node;
}
// convert first letter/word of first paragraph to <Dropcap word="{whatever}">
if (needsDropcap && !dropcapAdded && isParagraph(node)) {
addDropcap(node);
dropcapAdded = true;
}
// add `count` prop to each <Sidenote> component
if (isSidenote(node)) {
// increment the counter first so that the count starts at 1
sidenotesCount += 1;
addSidenoteCount(node, sidenotesCount);
}
});
// insert our imports at the top of the `<script context="module">` tag
if (imports.size > 0) {
const script = moduleScript.value;
// split the script where the opening tag ends
const i = script.indexOf('>');
const openingTag = script.slice(0, i + 1);
const remainder = script.slice(i + 1);
// mdvsex uses tabs so we will as well
const importScript = Array.from(imports).join('\n\t');
moduleScript.value = `${openingTag}\n\t${importScript}${remainder}`;
}
// const name = vfile.filename.split('/').findLast(() => true);
// writeFileSync(`scratch/${name}.json`, JSON.stringify(tree, undefined, 4));
}
}
function processHeading(node) {
const level = node.tagName.slice(1);
node.tagName = 'Heading';
node.properties.level = level;
node.properties.id = makeSlug(toText(node));
}
function addDropcap(par) {
let txtNode = find(par, {type: 'text'});
const i = txtNode.value.search(/\s/);
const firstWord = txtNode.value.slice(0, i);
const remainder = txtNode.value.slice(i);
par.children.unshift({
type: 'raw',
value: `<Dropcap word="${firstWord}" />`,
});
txtNode.value = remainder;
}
function addSidenoteCount(node, count) {
// get the index of the closing >
const i = node.value.search(/>\s*$/);
if (i < 0) {
throw new Error('Failed to add counter to element, closing angle bracket not found.');
}
// splice in the count prop
node.value = `${node.value.slice(0, i)} count={${count}}>`;
}
function isHeading(node) {
return node.type === 'element' && node.tagName.match(/h[1-6]/);
}
function isModuleScript(node) {
return node.type === 'raw' && node.value.match(/^<script context="module">/);
}
function isParagraph(node) {
return node.type === 'element' && node.tagName === 'p';
}
function isSidenote(node) {
return node.type === 'raw' && node.value.match(/<\s*Sidenote/);
}

View File

@ -1,52 +0,0 @@
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) => {
if (vfile.data.fm.toc === false) {
return;
}
let toc = [];
let description = null;
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});
}

View File

@ -1,169 +0,0 @@
<script context="module">
import { writable } from 'svelte/store';
let activePreview = writable(null);
</script>
<script>
import { tick } from 'svelte';
import data from './books.json';
const images = import.meta.glob('./images/*.jpg', {eager: true});
export let ref;
const {type, title, author, description, url} = data[ref];
const imageUrl = images[`./images/${ref}.jpg`].default;
$: visible = $activePreview === ref;
let mousePresent = false;
let offset, popover;
async function show() {
$activePreview = ref;
mousePresent = true;
await tick();
const rect = popover.getBoundingClientRect();
// 12px is approximately var(--content-padding)
if (rect.x < 12) {
offset = `${12 - rect.x}px`;
}
}
function hide() {
mousePresent = false;
// mouseenter fires when the mouse moves into the floating div as well,
// so this gives us a "grace period" that applies to either anchor or popover
setTimeout(
() => {
if (!mousePresent && $activePreview === ref) {
$activePreview = null;
}
},
300
);
}
function clickLink(evt) {
// if click happened without hover, then we must be on mobile
if (!visible) {
$activePreview = ref;
evt.preventDefault();
}
// if visible, but mouse is not present, also mobile
else if (visible && !mousePresent) {
$activePreview = null;
evt.preventDefault();
}
}
let detailsLink;
function blurLink(evt) {
// do this in the next task, in case the click was inside the popover
setTimeout(
() => {
// check this here in case it got changed by a different event handler
if ($activePreview == ref) {
$activePreview = null;
}
},
0
)
// if ($activePreview == ref) {
// setTimeout(() => $activePreview = null, 0);
// }
}
</script>
<style lang="scss">
.base {
position: relative;
// on mobile, we want the popover's position to be calculated
// relative to the whole document, not the link text
@media(max-width: 27rem) {
position: static;
}
}
.popover {
position: absolute;
// popover should float above the link text by a bit
bottom: calc(100% + 0.5rem);
// and be centered relative to the link, unless that would put it off screen
left: 50%;
transform: translateX(
calc(-50% + var(--offset, 0px))
);
@media(max-width: 27rem) {
// bounding box is now the whole document
// we want to start from its initial vertical position
bottom: unset;
// center it horizontally, with some space on the sides
width: unset;
left: 1rem;
right: 1rem;
margin-left: auto;
margin-right: auto;
// and move it back up so it's above the text again
transform: translateY(
calc(-100% - 1.5em - 0.5rem)
);
}
// visibility is controlled by the .visible class
display: none;
&.visible {
display: flex;
}
// two-column layout, one for image and one for text
gap: 1rem;
width: 25rem;
height: 192px;
overflow-y: auto;
padding: 0.35rem;
background: white;
box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.1);
border: 1px solid var(--content-color);
z-index: 1;
font-size: var(--content-size-sm);
}
img {
height: 100%;
// sticky position ensures that the image stays visible when we scroll the text
position: sticky;
top: 0;
}
a.details {
color: var(--primary-color);
&:visited {
color: var(--accent-color);
}
}
a:active {
color: var(--accent-color);
}
</style>
<span class="base" on:mouseenter={show} on:mouseleave={hide}><!-- get rid of whitespace
--><a href={url} target="_blank" on:click={clickLink} on:blur={blurLink}>
<slot></slot><!--
--></a><!--
--><div class="popover" bind:this={popover} class:visible style:--offset={offset}>
<img src={imageUrl}>
<div>
<h4>{title}</h4>
<p>
{description}
<a class="details" href={url} target="_blank" bind:this={detailsLink}>More</a>
</p>
</div>
</div><!--
--></span>

View File

@ -1,23 +0,0 @@
{
"lotr": {
"type": "trilogy",
"title": "The Lord of the Rings",
"author": "J. R. R. Tolkien",
"description": "Epic fantasy trilogy written by Oxford professor and linguist J. R. R. Tolkien. Considered by many to be the major trend-setter for the modern fantasy genre.",
"url": "https://www.goodreads.com/series/66175-the-lord-of-the-rings"
},
"neverwhere": {
"type": "book",
"title": "Neverwhere",
"author": "Neil Gaiman",
"description": "Under the streets of London there's a world most people could never dream of. A city of monsters and saints, murderers and angels, knights in armour and pale girls in black velvet. \"Neverwhere\" is the London of the people who have fallen between the cracks.",
"url": "https://www.goodreads.com/book/show/14497.Neverwhere"
},
"earthsea": {
"type": "series",
"title": "Earthsea Cycle",
"author": "Ursula K. Le Guin",
"description": "Series of high fantasy stories set in an archipelago world where names are power and dragons roam the skies.",
"url": "https://www.goodreads.com/series/40909-earthsea-cycle"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

View File

@ -1,2 +0,0 @@
import '../styles/main.scss';
export const prerender = true;

View File

@ -1,40 +0,0 @@
<style lang="scss">
.header {
background: var(--primary-color-faded);
}
nav {
max-width: 30rem;
margin: 0 auto;
display: flex;
justify-content: space-between;
& a {
flex: 1;
max-width: 8rem;
padding: 0.25rem 1rem;
font-size: 1.75rem;
color: white;
text-decoration: none;
text-align: center;
&:hover {
background: hsl(0deg 0% 0% / 10%);
}
}
}
</style>
<div class="header">
<nav>
<a data-sveltekit-preload-data="hover" href="/">Home</a>
<a data-sveltekit-preload-data="hover" href="/posts">Posts</a>
<a data-sveltekit-preload-data="hover" href="/about">About</a>
</nav>
</div>
<main>
<slot></slot>
</main>

View File

@ -1,8 +0,0 @@
export async function load({ data }) {
let post = await import(`./_posts/${data.slug}.svx`);
post.metadata.slug = data.slug;
post.metadata.next = data.next;
return {
post: post.default,
}
}

View File

@ -1,11 +0,0 @@
import { postData, siblingPosts } from './_posts/all.js';
// this is in a "servserside" loader so that we don't end up embedding the metadata
// for every post into the final page
export function load() {
return {
slug: postData[0].slug,
next: postData[1].slug,
};
}

View File

@ -1,5 +0,0 @@
<script>
export let data;
</script>
<svelte:component this={data.post} />

View File

@ -1,14 +0,0 @@
<style>
h1 {
margin-top: 6rem;
}
h1, p {
text-align: center;
}
</style>
<h1>404</h1>
<p>That page doesn't exist. Sorry!</p>

24
src/routes/[slug].svelte Normal file
View File

@ -0,0 +1,24 @@
<script context="module">
export async function load({ url, params }) {
try {
let post = await import(`./_posts/${params.slug}.svx`);
return {
props: {
post: post.default
}
}
}
catch (err) {
return {
status: 404,
error: `Not found: ${url.pathname}`,
}
}
}
</script>
<script>
export let post;
</script>
<svelte:component this={post} />

View File

@ -1,24 +0,0 @@
import { error } from '@sveltejs/kit';
export async function load({ url, params, data }) {
let post;
try {
post = await import(`../_posts/${params.slug}.svx`);
}
catch (err) {
if (err.message.match(/Unknown variable dynamic import/)) {
throw error(404, `Not found: ${url.pathname}`);
}
else {
throw err;
}
}
post.metadata.slug = params.slug;
post.metadata.prev = data.prev;
post.metadata.next = data.next;
return {
post: post.default,
}
}

View File

@ -1,10 +0,0 @@
import { postData } from '../_posts/all.js';
export function load({ params }) {
const i = postData.findIndex(p => p.slug === params.slug);
return {
prev: i > 0 ? postData[i - 1].slug : null,
next: i < postData.length - 1 ? postData[i + 1].slug : null,
};
}

View File

@ -1,5 +0,0 @@
<script>
export let data;
</script>
<svelte:component this={data.post} />

View File

@ -0,0 +1,45 @@
<style>
:global(main) {
--content-width: 42rem;
box-sizing: border-box;
max-width: var(--content-width);
margin: 0 auto;
padding: 0 15px;
}
#header {
background-color: #4f5f68;
}
#nav-main {
max-width: 30rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, minmax(6rem, 8rem));
justify-content: space-between;
}
#nav-main a {
font-size: 1.5rem;
color: white;
text-decoration: none;
text-align: center;
padding: 0.25rem 0;
}
#nav-main a:hover {
background-color: #00000025;
}
</style>
<div id="header">
<nav id="nav-main">
<a sveltekit:prefetch href="/">Home</a>
<a sveltekit:prefetch href="/posts">Posts</a>
<a sveltekit:prefetch href="/">About</a>
</nav>
</div>
<main>
<slot></slot>
</main>

View File

@ -1,113 +0,0 @@
---
title: Axes of Fantasy
date: 2023-12-26
draft: true
---
<script>
import Sidenote from '$lib/Sidenote.svelte';
import BookPreview from '$projects/fantasy/BookPreview.svelte';
</script>
For a while now, I've had a private taxonomy of fantasy books, based on the distinction (or lack thereof) between the fantasy world and our own. It goes something like this:
* *High Fantasy* is set in a world completely separate from our own, with no passage from one to the other. At the most, there might be faint hints that the fantasy world represents ours in the distant past.
* *Low Fantasy* is set in a fantasy world that is separate from ours, but that can be reached (at least some of the time) by some means, such as a portal, magic object, ritual, etc.
* *Urban Fantasy* is fantasy in which the fantasy world is contained _within_ our world, but typically hidden from the prying eyes of mere mortals in some fashion.
I refer to this as a "personal" taxonomy because as far as I can tell, nobody else shares it. The terms are well-known, of course, and there's some overlap--what most people call "High Fantasy" certainly does tend to be set in fantasy worlds with no connection to our own - but "Urban Fantasy" in particular means a whole lot more to most people than just "fantasy set in our world." Most people seem to agree that urban fantasy should be, well, urban - not that it has to take place in _cities_ stricly speaking, but it should at least portray the fantastical elements in and around the real world, and pay some attention to the question of how it stays out of sight of Muggles.
Obviously, my personal classification system is much simpler and stricter than this. To be honest, it's not terribly useful on its own - while the relationship between a fantasy world and our own is certainly _an_ attribute worth considering for classification purposes, it's far from the only one.
So then I got to thinking: If world-overlap is one "axis" along which fantasy can be ranked, what others are there? If we come up with a sufficiently comprehensive set of axes, can we start identifying existing labels (High Fantasy, Urban Fantasy, Epic Fantasy, etc.) as "clusters" of stories which share the same position on _multiple_ axes?
This means that the ideal basis for an axis should be:
* One-dimensional: Ideally, we'd like to be able to give each fantasy work a number, say from 1-100, so that any one is easily relatable to any other. This can't be a true ratiometric scale, obviously, but having something numeric makes it much easier to do fun stuff like searching for "neighbor" stories that sit near a given story on multiple axes.
* Orthogonal: Axes should be _conceptually_ unrelated to one another. Obviously a lot of axes will _tend_ to cluster, just like certain ingredients are commonly paired across a variety of dishes, but it should be possible _in principle_ for a story to occupy any positions on any given pair of axes.
* Objective: As much as possible, at least. Our existing axis of world overlap does well on this metric: it's usually pretty clear where a given story should fall. Sure, there are a few cases where different people might disagree about which of two stories has more or less overlap, but only when they have a very similar amount of overlap to start with. It doesn't seem likely that different people could end up placing the same story on opposite ends of the axis.
* Impactful: A story's position on the axis should go at least some way toward determining what kind of story it is. For example, the climate of the fantasy world would _not_ do well on this metric, since it doesn't matter a lot whether the world is hot or cold when you're asking how it should be classified.<Sidenote>The most impact I can imagine a fantasy world's climate having is something like the situation in A Song of Ice and Fire, where the extremely-long cycle of seasons (each cycle takes decades, if I recall correctly) lead to political differences because e.g. people younger than a certain age have never experienced a winter. But even then, it isn't the climate itself that most people would base their classification on, it's the political situation. It still seems largely incidental, _for classification purposes_, that the political complexity comes partly from environmental factors.</Sidenote>
Okay, so what different axes can we come up with? Obviously we can start with the original one that I wanted to base my taxonomy on:
## World overlap
This is an easy aspect to use for classification, because it's usually quite clear where a given setting should fall. At the left-most extreme you have what I'll call "Otherworld" fantasy, where the fantasy world has absolutely no connection at all to our own world. At the opposite end you'll find most "Urban" fantasy, where the world depicted _is_ the real world, just with added fantastical bits.
Notable subregions include:
### Otherworld Fantasy
No overlap at all. I think most fantasy that's written tends to fall here. At least, it's what most people think of when you say "fantasy book," and the Wikipedia definition of "Fantasy" specifies that it's "typically set in a fictional universe," so I think it's fair to say that this is the "standard" position for a fantasy story to occupy on this axis.
Examples: <BookPreview ref="lotr">_The Lord of the Rings_</BookPreview>, <BookPreview ref="earthsea">_Earthsea_</BookPreview>, _The Prydain Chronicles_, _Wheel of Time_, _Belgariad_, _A Song of Ice and Fire_, etc. Pick up a book from the fantasy section of a bookstore and there's at least a 50% chance it will fall into this category.
### Mythopoeic Fantasy
Much rarer than the previous category, stories of this type are set in the real world, but in a long-forgotten vanished age of which only the faintest echoes are now known. Think of the "A long time ago, in a galaxy far far away" intro to _Star Wars_<Sidenote>_Star Wars_, of course, isn't typically categorized as fantasy, but rather sci-fi - which is funny because _Star Wars_ has a lot more in common with most fantasy than most sci-fi. Starting with straight-up magic, i.e. the Force.</Sidenote> - the fantasy world has _some_ relation to our own, but for all practical purposes it might as well not exist.
The _Conan_ stories are the only ones I know of that fall clearly into this category, although I'm sure there must be others.
Interestingly enough Tolkien's original goal in writing developing his legendarium was to construct this type of setting. In his own words:<Sidenote>This comes from a letter that Tolkien wrote to Milton Waldman, who I believe was his publisher, in 1951.</Sidenote>
> I was from early days grieved by the poverty of my own beloved country: it had no stories of its own (bound up with its tongue and soil), not of the quality that I sought, and found (as an ingredient) in legends of other lands ... Do not laugh! But once upon a time (my crest has long since fallen) I had a mind to make a body of more or less connected legend, ranging from the large and cosmogonic, to the level of romantic fairy-story - the larger founded on the lesser in contact with the earth, the lesser drawing splendour from the vast backcloths - which I could dedicate simply to: to England, to my country.
Unfortunately, perhaps, for Tolkien, but quite fortunately for fans of modern fantasy as we see it today, he wound up creating what is pretty undeniably an Otherworld fantasy. There are occasional references to "nowadays" or "in later times" but even then, the conceit seems to be that the narrator is writing from the later days _of Middle Earth_.
### Portal Fantasy
We now make a rather significant jump into what I'm pretty sure is the second-largest category on this scale, which I'm calling "portal fantasy."<Sidenote>This is actually a term that I've seen used elsewhere, in contrast to the previous two which I just made up.</Sidenote> The "codifying" work for this category<Sidenote>In the same way the LOTR was codifying for much of otherworld fantasy, i.e. it's extremely common now for fantasy worlds to feature Elves, Dwarves, and Men, who all share roughly the same set of characteristics as Tolkien's versions. Even the spelling is Tolkien's - previously, "dwarves" would have been considered incorrect; the standard spelling was "dwarfs."</Sidenote> is, of course, the _Chronicles of Narnia_, but there are plenty of other examples. Apparently there's even a Japanese word for it, _isekai_.<Sidenote>I'm not at all familiar with this subgenre, so I don't know if it's exactly the same thing as what I'm calling "portal fantasy" or just shares some key traits with it.</Sidenote>
Note that the mere existence of portals between worlds, or some sort of established "multiverse," doesn't by itself qualify a story for this category. It's required that one of the worlds in question be _the real world_. Otherwise it's just a different flavor of otherworld fantasy. So no _Riftwar_, _Skyhold_, _Traitor Son Cycle_, etc.
One of the fun things about this classification is that it's a mini-axis in its own right, differentiated primarily by how easy or difficult it is to cross from the real world into the fantasy world or back again:
* On the left or "less overlapping" side you have stories like _Narnia_ or _The Last Rune_, where passage between worlds is spotty and _mostly_ doesn't happen at the behest of the characters, but by happenstance or by the action of some Greater Power that overstrides both worlds.
* Moving rightward, you find stories where points of passage are rare but knowable, usually requiring both a certain time and a certain place. _The Paradise War_ is a good example of this.
* Next you have stories where passage can seemingly be accomplished at any time, but requires a great deal of effort and/or arcane knowledge - think large assemblies of wizards gathered together, chanting in unison around a rune-inscribed circle that glows with eerie light, that sort of thing. _The Wizardry Compiled_ is pretty close to this, from what I remember.
* Finally there are some portal fantasies where the portal exists in a fixed location and can be crossed at any time, e.g. _Stardust_. This seems to be the rarest version, at least based on my own reading.
Other examples of portal fantasy include _The Chronicles of Amber_,<Sidenote>Originally I actually had this split into a separate category that I was going to call "nested-world fantasy", but on further reflection I realized that didn't make sense because a) if the fantasy world is nested inside the real world then it's just some verion of [urban fantasy](#urban-fantasy), and b) if it's the other way around, well, every portal fantasy already postulates the existence of some sort of "magical multiverse" that also contains the real world, and it's fundamentally no different whether the main story is set in the multiverse as a whole or just in some particular part of it.</Sidenote> _The Fionavar Tapestry_, the _Oz_ books, _Droon_,<Sidenote>I'm only putting this here for completeness, not because I've read a bunch of them or anything. _furtive glances from side to side_</Sidenote> the _Fairyland_ books, _The Phantom Tollbooth_,<Sidenote>I think, at least? I never actually finished this one.</Sidenote>, _Shades of Magic_, and _The Keys to the Kingdom_. There are buckets more, but that's all I can think of right now. Plus, this isn't meant to be an exhaustive catalog or anything.
### Alternate History
Fantasy that's set in our world, but with magic.<Sidenote>Or other fantastical elements, of course. Doesn't have to be literally magic-with-an-M.</Sidenote> I hemmed and hawed a lot about whether to even give this category a spot on this scale. You could easily make the argument that a fantasy version of the real world is just a different world, and all of these stories belong in the Otherworld category.
In the end, though, I decided that the point of this axis is to classify fantasy according to how much the fantasy world overlaps with our own, and alternate history involves _quite a lot of overlap_, even though the end result is a world that's not _quite_ identical with the real world.
Broadly speaking there are two variants of alternate history: 1) Either the fantastic has always been a part of life, or 2) it was suddenly introduced into the world by some (usually fairly cataclysmic) event.
Examples of the first variant include _Cecelia and Kate_, the _Temeraire_ books, _Jonathan Strange and Mr Norrell_, etc. An interesting quirk of this variant is that it's almost always set significantly in the past, but for some reason not _quite_ as far back as the quasi-Medieval era that is the bread and butter of most "standard" fantasy. The Napoleonic/Regency era is popular, as is the Victorian era. Modern-day alternate-history stories of this type seem fairly uncommon--_Bartimaeus_ is the only example I can think of off the top of my head.
Examples of the secont variant include _Unsong_, _Reckoners_, and _The Tapestry_. Stories of this type are much more commonly set in the modern day--understandably so, since "what would happen to society if magic were suddenly introduced" is a pretty interesting question to explore.
### Urban Fantasy
This is another term that you'll run across a lot if you do any research at all into fantasy subgenres. Here I'm using it in a very restricted sense, that is, _only_ to refer to the integration of the fantastical elements with the real world, without any of the other themes that are often indicated by the term.
To me, the defining characteristic of urban fantasy is that it's set in the real world, where the fantastical is _present_, but _hidden_. It _has_ to be hidden, because if it weren't then it would unavoidably have a major impact on the world, at which point we'd be back to alternate history.<Sidenote>The ever-relevant TVTropes has [some things to say](https://tvtropes.org/pmwiki/pmwiki.php/Main/Masquerade) on this subject as well.</Sidenote>
So urban fantasy depicts a world where there's magic<Sidenote>Or dragons, or fairies, or whatever fantastical elements the author wants. I'll just use "magic" generally to refer to "the fantastical" for the rest of this section.</Sidenote> but for whatever reason this is completely unknown to most people. Occasional exceptions may be made for top-secret government programs - it isn't that much of a stretch to imagine that if there were magic in the world, then at least some of the powers that be would be aware of it and using it to their advantage. The _Milkweed Triptych_ and the _Checquey Files_ are both examples of this variant.
For the most part, though, the people who know about magic are the people who have magic, plus the occasional Ascended Muggle Sidekick who's there for flavor (and to act as an audience surrogate, probably.) In fact, quite frequently the main conflict of the story is about _preventing_ the magical part of the world from being exposed, either because the magicians are afraid that a world full of angry normies would actually pose a threat to them<Sidenote>In this case the Salem witch trials and similar events are frequently invoked, in-universe as cautionary tales of what might happen "if _they_ find out about us."</Sidenote>, or because the wise and benevolent Wizards' Council has declared that even though they _could_ rule the world, it wouldn't be fair to the poor normies.
Other notable examples of the genre include _The Dresden Files_, _Percy Jackson and the Olympians_, _The Laundry Files_, _Neverwhere_, _American Gods_, the _Artemis Fowl_ books, the _Mither Mages_ series, the _Iron Druid_ series, _Monster Hunter International_, and of course _Harry Potter_.
## Quasi-Historical Era
Another good dimension for differentiating fantasy stories is their "era," so to speak: What real-world historical period provides the basis for the level of technology, social structures, etc?<Sidenote>This is necessarily bound up with the question of _where_ the fantasical cultures get their inspiration, but unfortunately that side of it isn't nearly as easy to map onto a single numerical scale, so I'm going to mostly ignore it for now.</Sidenote>
I don't think it's worth trying to be too precise with the exact historical placement of most fantasy works because most are filled with anachronisms and/or cobbled together from patchworks of different specific times and places,<Sidenote>E.g. the _Belgariad_, which has one country filled with more-or-less French knights of the late Medieval era, and another country populated by not-quite Vikings, in the same world.</Sidenote> so they don't really belong to _one_ precise era.<Sidenote>Exceptions, of course, being things like the _Traitor Son Cycle_, which is _very_ clearly set in an analogue of the late 14th century.</Sidenote> But you can usually get a rough sense of the aesthetic that the author is going for, in broad strokes.
### Antiquity
This is pretty rare, but there are a few examples. The _Codex Alera_ is set in something approximating Imperial Rome, and though I haven't read them I've heard that David Gemmell has written some in an Ancient-Greece-ish setting. I've heard rumors of some ancient Egyptian ones as well.
### Dark Ages
If anything, even rarer than the above. The only ones of which I am aware are the _Belisarius_ series,<Sidenote>Wikipedia terms this "Alternate history science fiction" but it isn't very science-y at all, from what I remember, and it _is_ more than a little magic-y, so I'll call it fantasy.</Sidenote> and (although I haven't read this one) the _Sarantine Mosaic_, both set around 500-600 AD.<Sidenote>So post-fall-of-Rome, which I think counts as Dark Ages.</Sidenote>
### Middle Ages
The _vast_ majority of fantasy is set in something with approximately the aesthetics of Medieval Europe. Tolkien, obviously, was the trend-setter here,<Sidenote>Although interestingly, Tolkien's work seems to clock in rather earlier than your Standard Formulaic Fantasy Setting 1A. Tolkien's world is closest to the _early_ middle ages (circa 1050 or so), from what I understand - e.g. his characters consistently use chain mail rather than plate armor; presumably plate armor doesn't exist in Middle Earth. The obvious reason for this, of course, is that this was the era Tolkien himself had studied most intensively - mostly its literature, from what I understand, but you can't become an expert in the literature of a period without developing at least _some_ sense of what life was like then. So naturally, this was what he drew on when crafting his fantasy settings. Later fantasy, on the other hand, seems to draw most heavily on the _late_ middle ages, circa 1300-1500, with plate armor, chivalry, the occasional joust, etc.</Sidenote>

View File

@ -31,7 +31,7 @@ docker network create \\
-o parent=eth0 \\
lan
docker run --network lan --ip 192.168.50.24 some/image:tag
docker run --network lan --ip 192.168.50.24 some/image:version
```
That's it! You're done, congratulations. (Obviously `--subnet`, `--gateway`, and `--parent` should be fed values appropriate to your network.)

View File

@ -3,7 +3,6 @@ title: Imagining A Passwordless Future
description: Can we replace passwords with something more user-friendly?
date: 2021-04-30
draft: true
dropcap: false
---
<script>
import Sidenote from '$lib/Sidenote.svelte';

View File

@ -1,101 +0,0 @@
---
title: The Kubernetes Alternative I Wish Existed
date: 2023-10-01
draft: true
---
<script>
import Sidenote from '$lib/Sidenote.svelte';
</script>
I use Kubernetes on my personal server, largely because I wanted to get some experience working with it. It's certainly been helpful in that regard, but after a year and a half or so I think I can pretty confidently say that it's not the ideal tool for my use-case. Duh, I guess? But I think it's worth talking about _why_ that's the case, and what exactly _would_ be the ieal tool.
## The Kubernetes Way™
Kubernetes is a very intrusive orchestration system. It would very much like the apps you're running to be doing things _its_ way, and although that's not a _hard_ requirement it tends to make everything subtly more difficult when that isn't the case. In particular, Kubernetes is targeting the situation where you:
* Have a broad variety of applications that you want to support,
* Have written all or most of those applications yourself,<Sidenote>"You" in the organizational sense, not the personal one.</Sidenote>
* Need those applications to operate at massive scale, e.g. concurrent users in the millions.
That's great if you're Google, and surprise! Kubernetes is a largely Google-originated project,<Sidenote>I'm told that it's a derivative of Borg, Google's in-house orchestration platform.</Sidenote> but it's absolute _garbage_ if you (like me) are just self-hosting apps for your own personal use and enjoyment. It's garbage because, while you still want to support a broad variety of applications, you typically _didn't_ write them yourself and you _most definitely don't_ need to scale to millions of concurrent users. More particularly, this means that the Kubernetes approach of expecting everything to be aware that it's running in Kubernetes and make use of the platform (via cluster roles, CRD's etc) is very much _not_ going to fly. Instead, you want your orchestration platform to be as absolutely transparent as possible: ideally, a running application should need to behave no differently in this hypothetical self-hosting-focused orchestration system than it would if it were running by itself on a Raspberry Pi in your garage. _Most especially_, all the distributed-systems crap that Kubernetes forces on you is pretty much unnecessary, because you don't need to support millions<Sidenote>In fact, typically your number of concurrent users is going to be either 1 or 0.</Sidenote> of concurrent users, and you don't care if you incur a little downtime when the application needs to be upgraded or whatever.
## But Wait
So then why do you need an orchestration platform at all? Why not just use something like [Harbormaster](https://gitlab.com/stavros/harbormaster) and call it a day? That's a valid question, and maybe you don't! In fact, it's quite likely that you don't - orchestration platforms really only make sense when you want to distribute your workload across multiple physical servers, so if you only have the one then why bother? However, I can still think of a couple of reasons why you'd want a cluster even for your personal stuff:
* You don't want everything you host to become completely unavailable if you bork up your server somehow. Yes, I did say above that you can tolerate some downtime, and that's still true - but especially if you like tinkering around with low-level stuff like filesystems and networking, it's quite possible that you'll break things badly enough<Sidenote>And be sufficiently busy with other things, given that we're assuming this is just a hobby for you.</Sidenote> that it will be days or weeks before you can find the time to fix them. If you have multiple servers to which the workloads can migrate while one is down, that problem goes away.
* You don't want to shell out up front for something hefty enough to run All Your Apps, especially as you add more down the road. Maybe you're starting out with a Raspberry pi, and when that becomes insufficient you'd like to just add more Pis rather than putting together a beefy machine with enough RAM to feed your [Paperless](https://github.com/paperless-ngx/paperless-ngx) installation, your [UniFi controller](https://help.ui.com/hc/en-us/articles/360012282453-Self-Hosting-a-UniFi-Network-Server), your Minecraft server(s), and your [Matrix](https://matrix.org) server.
* You have things running in multiple geographical locations and you'd like to be able to manage them all together. Maybe you built your parents a NAS with Jellyfin on it for their files and media, or you run a tiny little proxy (another Raspberry Pi, presumably) in your grandparents' network so that you can inspect things directly when they call you for help because they can't print their tax return.
Okay, sure, maybe this is still a bit niche. But you know what? This is my blog, so I get to be unrealistic if I want to.
## So what's different?
Our hypothetical orchestrator system starts out in the same place as Kubernetes--you have a bunch of containerized applications that need to be run, and a pile of physical servers on which you'd like to run them. You want to be able to specify at a high level in what ways things should run, and how many of them, and so on. You don't want to worry about the fiddly details like deciding which container goes on which host, or manually moving all of `odin`'s containers to `thor` when the Roomba runs over `odin`'s power cable while you're on vacation on the other side of the country. You _might_ even want to be able to specify that a certain service should run _n_ replicas, and be able to scale that up and down as needed, though that's a decidedly less-central feature for our orchestrator than it is for Kubernetes. Like I said above, you don't typically need to replicate your services for traffic capacity, so _if_ you're replicating anything it's probably for availability reasons instead. But true HA is usually quite a pain to achieve, especially for anything that wasn't explicitly designed with that in mind, so I doubt a lot of people bother.
So that much is the same. But we're going to do everything else differently.
Where Kubernetes is intrusive, we want to be transparent. Where Kubernetes is flexible and pluggable, we will be opinionated. Where Kubernetes wants to proliferate statelessness and distributed-systems-ism, we will be perfectly content with stateful monotliths.<Sidenote>And smaller things, too. Microliths?</Sidenote> Where Kubernetes expects cattle, we will accept pets. And so on.
The basic resources of servering are ~~wheat~~ ~~stone~~ ~~lumber~~ compute, storage, and networking, so let's look at each in detail.
## Compute
"Compute" is an amalgamate of CPU and memory, with a side helping of GPU when necessary. Obviously these are all different things, but they tend to work together more directly than either of them does with the other two major resources.
### Scheduling
Every orchestrator of which I am aware is modeled as a first-class distributed system: It's assumed that it will consist of more than one instance, often _many_ more than one, and this is baked in at the ground level.<Sidenote>Shoutout to [K3s](https://k3s.io) here for bucking this trend a bit: while it's perfectly capable of functioning in multi-node mode, it's capable of running as a single node and just using SQLite as its storage backend, which is actually quite nice for the single-node use case.</Sidenote>
I'm not entirely sure this needs to be the case! Sure, for systems like Kubernetes that are, again intended to map _massive_ amounts of work across _huge_ pools of resources it definitely makes sense; the average `$BIGCORP`-sized Kubernetes deployment probably couldn't even _fit_ the control plane on anything short of practically-a-supercomputer. But for those of us who _don't_ have to support massive scale, I question how necessary this is.
The obvious counterpoint is that distributing the system isn't just for scale, it's also for resiliency. Which is true, and if you don't care about resiliency at all then you should (again) probably just be using Harbormaster or something. But here's the thing: We care about stuff running _on_ the cluster being resilient, but how much do we care about the _control plane_ being resilient? If there's only a single control node, and it's down for a few hours, can't the workers just continue happily running their little things until told otherwise?
We actually have a large-scale example of something sort of like this in the recent Cloudflare outage.
### Virtualization
The de facto standard unit of virtualization in the orchesetration world is the container. Containers have been around in one form or another for quite a while, but they really started to take off with the advent of Docker, because Docker made them easy. I want to break with the crowd here, though, and use a different virtualization primitive, namely:
#### AWS Firecracker
You didn't write all these apps yourself, and you don't trust them any further than you can throw them. Containers are great and all, but you'd like a little more isolation. Enter Firecracker. This does add some complexity where resource management is concerned, especially memory, since by default Firecracker wants you to allocate everything up front. But maybe that's ok, or maybe we can build in some [ballooning](https://github.com/firecracker-microvm/firecracker/blob/main/docs/ballooning.md) to keep things under control.
VM's are (somewhat rightfully) regarded as being a lot harder to manage than containers, partly because (as mentioned previously) they tend to be less flexible with regard to memory requirements, but also because it's typically a lot more difficult to do things like keep them all up to date. Managing a fleet of VM's is usually just as operationally difficult as managing a fleet of physical machines.
But [it doesn't have to be this way!](https://fly.io/blog/docker-without-docker/) It's 2023 and the world has more or less decided on Docker<Sidenote>I know we're supposed to call them "OCI Images" now, but they'll always be Docker images to me. Docker started them, Docker popularized them, and then Docker died because it couldn't figure out how to monetize an infrastructure/tooling product. The least we can do is honor its memory by keeping the name alive.</Sidenote> images as the preferred format for packaging server applications. Are they efficient? Hell no. Are they annoying and fiddly, with plenty of [hidden footguns](https://danaepp.com/finding-api-secrets-in-hidden-layers-within-docker-containers)? You bet. But they _work_, and they've massively simplified the process of getting a server application up and running. As someone who has had to administer a Magento 2 installation, it's hard not to find that appealing.
They're especially attractive to the self-hosting-ly inclined, because a well-maintained Docker image tends to keep _itself_ up to date with a bare minimum of automation. I know "automatic updates" are anathema to some, but remember, we're talking self-hosted stuff here--sure, the occasional upgrade may break your Gitea<Sidenote>Or maybe not. I've been running Gitea for years now and never had a blip.</Sidenote> server, but I can almost guarantee that you'll spend less time fixing that than you would have manually applying every update to every app you ever wanted to host, forever.
So we're going to use Docker _images_ but we aren't going to use Docker to run them. This is definitely possible, as alluded to above. Aside from the linked Fly post though, other [attempts](https://github.com/weaveworks-liquidmetal/flintlock) in the same [direction](https://github.com/firecracker-microvm/firecracker-containerd) don't seem to have taken off, so there's probably a fair bit of complexity here that needs to be sorted out.
## Networking
Locked-down by default. You don't trust these apps, so they don't get access to the soft underbelly of your LAN. So it's principle-of-least-privilege all the way. Ideally it should be possible when specifying a new app that it gets network access to an existing app, rather than having to go back and modify the existing one.
## Storage
Kubernetes tends to work best with stateless applications. It's not entirely devoid of [tools](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) for dealing with state, but state requires persistent storage and persistent storage is hard in clusters.<Sidenote>In fact, I get the sense that for a long time you were almost completely on your own with storage, unless you were using a managed Kubernetes project like GKE where you're just supposed to use whatever the provider offers for storage. More recently things like Longhorn have begun improving the situation, but "storage on bare-metal Kubernetes" still feels decidedly like a second-class citizen to me.</Sidenote>
Regardless, we're selfhosting here, which means virtually _everything_ has state. But fear not! Distributed state is hard, yes, but most of our apps aren't going to be truly distributed. That is, typically there's only going to be one instance running at a time, and it's acceptable to shut down the existing instance before spinning up a new one. So let's look at what kind of complexity we can avoid by keeping that in mind.
**We don't need strong consistency:** You're probably just going to be running a single instance of anything that involves state. Sure, you can have multiple SQLite processes writing to the same database and it _can_ be ok with that, unless it isn't.<Sidenote> From the SQLite FAQ: "SQLite uses reader/writer locks to control access to the database. But use caution: this locking mechanism might not work correctly if the database file is kept on an NFS filesystem. This is because `fcntl()` file locking is broken on many NFS implementations."</Sidenote> But you _probably_ don't want to run the risk of data corruption just to save yourself a few seconds of downtime.
This means that whatever solution we come up with for storage is going to be distributed and replicated almost exclusively for durability reasons, rather than for keeping things in sync. Which in turn means that it's _probably fine_ to default to an asynchronous-replication mode, where (from the application's point of view) writes complete before they're confirmed to have safely made it to all the other replicas in the cluster. This is good because the storage target will now appear to function largly like a local storage target, rather than a networked one, so applications that were written with the expectation of using local storage for their state will work just fine. _Most especially_, this makes it _actually realistic_ to distribute our storage across multiple geographic locations, whereas with a synchronous-replication model the latency impact of doing that would make it a non-starter.
**Single-writer, multi-reader is default:** With all that said, inevitably people are going to find a reason to try mounting the same storage target into multiple workloads at once, which will eventually cause conflicts. There's only so much we can do to prevent people from shooting themselves in the foot, but one easy win would be to default to a single-writer, multi-reader mode of operation. That way at least we can prevent write conflicts unless someone intentionally flips the enable-write-conflicts switch, in which case, well, they asked for it.
## Configuration
YAML, probably? It's fashionable to hate on YAML right now, but I've always found it rather pleasant.<Sidenote>Maybe people hate it because their primary experience of using it has been in Kubernetes manifests, which, fair enough.</Sidenote> JSON is out because no comments. TOML is out because nesting sucks. Weird niche supersets of JSON like HuJSON and JSON5 are out because they've been around long enough that if they were going to catch on, they would have by now. Docker Swarm config files<Sidenote>which are basically just Compose files with a few extra bits.</Sidenote> are my exemplar par excellence here. (comparison of Kubernetes and Swarm YAML?) (Of course they are, DX has always been Docker's Thing.)
We are also _definitely_ going to eschew the Kubernetes model of exposing implementation details in the name of extensibility.<Sidenote>See: ReplicaSets, EndpointSlices. There's no reason for these to be first-class API resources like Deployments or Secrets, other than to enable extensibility. You never want users creating EndpointSlices manually, but you might (if you're Kubernetes) want to allow an "operator" service to fiddle with them, so you make them first-class resources because you have no concept of the distinction between external and internal APIs.</Sidenote>
## Workload Grouping
It's always struck me as odd that Kubernetes doesn't have a native concept for a heterogenous grouping of pods. Maybe it's because Kubernetes assumes it's being used to deploy mostly microservices, which are typically managed by independent teams--so workloads that are independent but in a provider/consumer relationship are being managed by different people, probably in different cluster namespaces anyway, so why bother trying to group them?
Regardless, I think Nomad gets this exactly right with the job/group/task hierarchy. I'd like to just copy that wholesale, but with more network isolation.

View File

@ -1,30 +0,0 @@
---
title: 'Mixing GUIs and CLIs on Windows: A Cautionary Tale'
date: 2024-06-17
---
<script>import Sidenote from '$lib/Sidenote.svelte';</script>
If you've used desktop Linux, then I'm sorry for you.<Sidenote>I also use desktop Linux. I'm sorry for me, too.</Sidenote> You will, however, most likely be familiar with the practice of using the same app from either the CLI or the GUI, depending on how you invoke it and what you want to do with it. In some cases, the CLI merely replicates the functionality of the GUI, but (due to being, you know, a CLI) is much easier to incorporate in scripts and such. In other cases ([Wezterm](https://wezfurlong.org/wezterm/) is a good example) the GUI app acts as a "server" with which the CLI communicates to cause it to do various things while it runs.
On Linux, this is as natural as breathing. There's nothing in particular that distinguishes a "GUI app" from a "CLI app", other than that the GUI app _happens_ to ultimately call whatever system APIs are involved in creating windows, drawing text, and so on. Moreover, even when running its GUI, a Linux app always has stdout, stderr, etc. In most day-to-day usage, these get stashed in some inscrutable location at the behest of Gnome or XFCE or whatever ends up being responsible for spawning and babysitting GUI apps most of the time, but you can always see them if you launch the app from a terminal instead. In fact, this is a common debugging step when an app is misbehaving: launch it from a terminal so you can see whether it's spitting out errors to console that might help diagnose the problem.
Since Windows also has both GUIs and CLIs, you might naively expect the same sort of thing to work there, but woe betide you if you try to do this. Windows thinks every app must be _either_ a GUI app or a CLI app, and never the twain shall meet, or at lest never the twain shall meet without quite a significant degree of jank.
Every Windows executable is flagged somehow<Sidenote>I don't know how precisely, probably a magic bit somewhere in the executable header or something like that.</Sidenote> as "GUI app" or "CLI app", and this results in different behavior on launch. CLI apps are allocated a [console](https://learn.microsoft.com/en-us/windows/console/definitions), on which concept I'm not _entirely_ clear but which seems somewhat similar to a [pty](https://man7.org/linux/man-pages/man7/pty.7.html) on Linux. GUI apps, on the other hand, are not expected to produce console output and so are not allocated a console at all, which means that if they try to e.g. write to stdout they just... don't. I'm not sure what exactly happens when they try: in my experience e.g. `println!()` in Rust just becomes a no-op, but it's possible that this is implemented on the Rust side because writing to stdout from a GUI app would crash your program otherwise, or something.
Aha, says the clever Windows developer, but I am a practitioner of the Deep Magicks, and I know of APIs such as [`AllocConsole`](https://learn.microsoft.com/en-us/windows/console/allocconsole) and [`FreeConsole`](https://learn.microsoft.com/en-us/windows/console/freeconsole) which allow an app to control the existence and attached-ness of its Windows consoles. But not so fast, my wizardly acquaintance. Yes, you can do this, but it's still _janky as hell_. There are two basic approaches: you can either a) flag the executable as a GUI app, then call `AllocConsole` and `AttachConsole` to get a console which can then be used for stdout/err/etc, or you can b) flag the executable as a CLI app, so it gets allocated a console by default, then call `FreeConsole` to get rid of it if you decide you don't want it.
If you do a), the problem is that the app doesn't have a console at its inception, so `AllocConsole` creates an entirely _new_ console, with no connection to the console from which you invoked the app. So it pops up in a new window, which is typically the default terminal emulator<Sidenote>On Windows 10 and earlier, this defaults to `conhost.exe`, which is the terminal emulator equivalent of a stone knife chipped into chape by bashing it against other stones.</Sidenote> rather than whatever you have set up, and - even worse - _it disappears as soon as your app exits_, because of course its lifecycle is tied to that of the app. So the _extremely standard_ CLI behavior of "execute, print some output, then exit" doesn't work, because there's no time to _read_ that output before the app exits and the window disappears.
Alternatively, you can call `AttachConsole` with the PID of the parent process, or you can just pass `-1` instead of a real PID to say "use the console of the parent process". But this almost as terrible, because - again - the app _doesn't have a console when it launches_, so whatever shell you used to launch it will just assume that it doesn't need to wait for any output and blithely continue on its merry way. If you then attempt to write to stdout, you _will_ see the output, but it will be interleaved with your shell prompt, keyboard input, and so on, so again, not really usable.
Ok, so you do b) - flag your app as a CLI app, then call `FreeConsole` as soon as it launches to detach from the console that gets automatically assigned to it. Unfortunately this doesn't work either. When you launch a CLI app in a context that expects a GUI, such as the Start menu, it gets assigned a brand-new console window, again using whatever is the default terminal emulator. In my experience, it isn't consistently possible (from within the process at least) to call `FreeConsole` quickly enough to prevent this window from at least flashing briefly on the desktop. Livable? Sure, I guess, but it would be a sad world indeed if we never aimed higher than just _livable_.
Up until now, my solution has been to simply create two copies of my executable, one GUI and one CLI, put them in different directories, and add the directory of the CLI executable to my `PATH` so that it's the one that gets invoked when I run `mycommand` in a terminal. This works ok, despite being fairly inelegant, but just today I discovered a better way via [this rant](https://www.devever.net/~hl/win32con).<Sidenote>With whose sentiments I must agree in every particular.</Sidenote> Apparently you can specify the `CREATE_NO_WINDOW` flag when creating the process, which prevents it from creating a new window. Unfortunately, as that page notes, this requires you to control the invocation of the process, so the only way to make proper use of it is to create a "shim" executable that calls your main executable (which will be, in this case, the CLI-first version) with the `CREATE_NO_WINDOW` flag, for when you want to run in GUI mode. That post also points out that if you have a `.exe` file and a `.com` file alongside each other, Windows will prefer the `.com` file when the app is invoked via the CLI, so your shim can be `app.exe` and your main executable `app.com`. I haven't tried this yet myself, but it sounds like it would work, and it's a general enough solution<Sidenote>One could even imagine a generalized "shim" executable which, when executed, simply looks at its current executable path, then searches in the same directory for another executable with the same name but the `.com` extension, and executes that with the `CREATE_NO_WINDOW` flag.</Sidenote> that app frameworks such as [Tauri](https://tauri.app/)<Sidenote>Building a Tauri app is how I encountered this problem in the first place, so I would be _quite_ happy if Tauri were to provide a built-in solution.</Sidenote> might eventually handle it for you.
Another solution that was suggested to me recently (I think this is the more "old school" way of handling this problem) is to create a new virtual desktop, then ensure that the spurious console window gets created there so that it's out of sight. I haven't tried this myself, so I'm not familiar with the details, but my guess is that like the above it would require you to control the invocation of the process, so there isn't really any advantage over the other method, and it's still hacky as hell.
Things like this really drive home to me how thoroughly Windows relegates the CLI to being a second-class citizen. In some ways it almost feels like some of the products of overly-optimistic 1960s-era futurism, like [designing a fighter jet without a machine gun](https://en.wikipedia.org/wiki/McDonnell_Douglas_F-4_Phantom_II?useskin=vector) because we have guided missiles now, and _obviously_ those are better, right? But no, in fact it turns out that sometimes a gun was _actually_ preferable to a guided missile, because surprise! Different tools have different strengths and weaknesses.
Of course, if Microsoft had been in charge of the F-4 it would have [taken them 26 years](https://devblogs.microsoft.com/commandline/windows-command-line-introducing-the-windows-pseudo-console-conpty/) to finally add the machine gun, and when they did it would have fired at a 30-degree angle off from the heading of the jet, so I guess we can be thankful that we don't have to use our terminal emulators for air-to-air dogfights, at least.

View File

@ -1,32 +0,0 @@
---
title: Password Strength, Hackers, and You
date: 2023-10-21
draft: true
---
<script>
import Sidenote from '$lib/Sidenote.svelte';
</script>
Every once in a while, as my friends and family can attest, I go off on a random screed about passwords, password strength, password cracking, logins, etc. To which they listen with polite-if-increasingly-glassy-eyed expressions, followed by an equally polite change of conversational topic. To avoid falling into this conversational tarpit _quite_ so often, I've decided to write it all up here, so that instead of spewing it into an unsuspecting interlocutor's face I can simply link them here.<Sidenote>Maybe I can get business cards printed, or something.</Sidenote> Whereupon they can say "Thanks, that sounds interesting," and proceed to forget that it ever existed. So it's a win-win: I get to feel like I've Made A Difference, and they don't have to listen to a half-hour of only-marginally-interesting infosec jargon.
So.
## Password Strength
Everyone knows that the "best" password is at least 27 characters long and contains a mix of upper and lowercase letters, symbols, atomic symbols, and ancient Egyptian hieroglyphs. What may be slightly less known is exactly _why_ this is the recommended approach to picking passwords, and how the same goal might be accomplished by other, less eye-gougingly awful means.
So how do we measure the "strength" of a password? Ultimately, for the purposes of our discussion here, password strength comes down to one thing: How many tries<Sidenote>On average, that is. Obviously (especially with randomly-chosen passwords) the _exact_ number of tries is going to be somewhat random.</Sidenote> would it take for someone to guess this password? There are two ~~facets~~ to this question: 1) How many possible passwords are there (this is sometimes referred to as the "key space"), and 2) How likely is each of them to be the correct password?
The first of those questions is pretty easy to answer in the most basic sense: The number of possible passwords is the maximum password length, raised to the power of the number of possible characters. For instance, if the maximum password length is 16 characters, and the number of possible characters is 95<Sidenote>I.e. uppercase + lowercase + symbols.</Sidenote>, then the
So what makes a "strong" password? Most people have a pretty good intuition for this, I think: A strong password is one that can't be easily guessed. The absolute _worst_ password is something that might be guessed by someone who knows nothing at all about you, such as `password` or `123456`<Sidenote>This is, in fact, the most common password (or was last I checked), according to [Pwned Passwords](https://haveibeenpwned.com/passwords).</Sidenote> Only slightly stronger is a password that's obvious to anyone who knows the slightest bit about its circumstances, such as your first name or the name of the site/service/etc. to which it logs you in.
Ok, so it's pretty clear what makes a _really_ bad password. But what about an only-sort-of-bad password? This is where intuition starts to veer off the rails a little bit, I think. The "guessability" of a password might be quantified as "how long, on average, would it take to guess"? Unfortuantely, the intuitive situation of "guessing" a password is pretty divergent from the reality of what a password cracker is actually doing when they try to crack passwords. Most people, based on the conversations I've had, envision "password guessing" as someone sitting at a computer, typing in potential passwords one by one. Or, maybe slightly more sophisticatedly, they imagine a computer firing off attempted logins from a list of potential passwords, but critically, _against the live system that is under attack._ This is a problem, because most password cracking (at least, the kind you have to worry about) _doesn't_ take place against live login pages. Instead, it happens in what's known as an "offline" attack, when the password cracker has managed to obtain a copy of the password database and starts testing various candidates against it. To explain this, though, we have to take a little detour into...
## Password storage
Unless the system in question is hopelessly insecure (and there are such systems; we'll talk about that in a bit) it doesn't store a copy of your password in plain text. Instead it stores what's called a _hash_, which is what you get when you run the password through a particular type of data-munging process called a _hashing algorithm_. A good password hashing algorithm has two key properties that make it perfect for this use case: It's _non-reversible_, and it's _computationally expensive_.
### One-way hashing
Suppose your password is `password`, and its hash is something like `X03MO1qnZdYdgyfeuILPmQ`. The non-reversibility of the hashing algorithm means that given the second value, there isn't any direct way to derive the first again. The only way to figure it out is to, essentially, guess-and-check against a list of potential candidate inputs. If that sounds a little bit like black magic, don't worry - I felt the same way when I first encountered the concept. How can a hash be irreversible _even if you know the algorithm_?

View File

@ -2,11 +2,12 @@
title: Sidenotes
description: An entirely-too-detailed dive into how I implemented sidenotes for this blog.
date: 2023-08-14
draft: true
---
<script>
import Dropcap from '$lib/Dropcap.svelte';
import Sidenote from '$lib/Sidenote.svelte';
import UnstyledSidenote from '$lib/UnstyledSidenote.svelte';
import Frame from '$lib/projects/sidenotes/Frame.svelte';
</script>
<style>
@ -63,7 +64,7 @@ draft: true
}
</style>
One of my major goals when building this blog was to have sidenotes. I've always been a fan of sidenotes on the web, because the most comfortable reading width for a column of text is <em>far</em> less than the absurd amounts of screen width we tend to have available, and what else are we going to use it for?<Sidenote>Some sites use it for ads, of course, which is yet another example of how advertising ruins everything.</Sidenote>
<Dropcap word="One">of my major goals when building this blog was to have sidenotes. I've always been a fan of sidenotes on the web, because the most comfortable reading width for a column of text is <em>far</em> less than the absurd amounts of screen width we tend to have available, and what else are we going to use it for?<Sidenote>Some sites use it for ads, of course, which is yet another example of how advertising ruins everything.</Sidenote></Dropcap>
Footnotes don't really work on the web the way they do on paper, since the web doesn't have page breaks. You _can_ stick your footnotes in a floating box at the bottom of the page, so they're visible at the bottom of the text just like they would be on a printed page, but this sacrifices precious vertical space.<Sidenote>On mobile, it's _horizontal_ space that's at a premium, so I do use this approach there. Although I'm a pretty heavy user of sidenotes, so I have to make them toggleable as well or they'd fill up the entire screen.</Sidenote> Plus, you usually end up with the notes further away from the point of divergence than they would be as sidenotes anyway.

View File

@ -1,84 +0,0 @@
---
title: 'Converting ssh keys from old formats'
date: 2024-07-06
---
<script>
import Sidenote from '$lib/Sidenote.svelte';
</script>
Like a lot of people, my main experience with private keys has come from using them for SSH. I'm familiar with the theory, of course - I know generally what asymmetric encryption does,<Sidenote>Although exactly _how_ it does so is still a complete mystery to me. I've looked up descriptions of RSA several times,<Sidenote>Testing nested notes again.</Sidenote> and even tried to work my way through a toy example, but it's never helped. And I couldn't even _begin_ to explain elliptic curve cryptography beyond "black math magic".</Sidenote> and I know that it means a compromised server can't reveal your private key, which is nice although if you only ever use a given private key to SSH into your server and the server is already compromised, is that really so helpful?<Sidenote>Yes, yes, I know that it means you can use the same private key for _multiple_ things without having to worry, but in practice a lot of people seem to use separate private keys for separate things, and even though I'm not entirely sure why I feel uncomfortable doing otherwise.</Sidenote>
What I was less aware of, however, was the various ways in which private keys can be _stored_, which rather suddenly became a more-than-purely-academic concern to me this past week. I had an old private key lying around which had originally been generated by AWS, and used a rather old format,<Sidenote>The oldest, I believe, that's in widespread use still.</Sidenote> and I needed it to be comprehensible by newer software which loftily refused to have anything to do with such outdated ways of expressing itself.<Sidenote>Who would write such obdurately high-handed software, you ask? Well, uh. Me, as it turns out. In my defense, though, I doubt it would have taken _less_ time to switch to a different SSH-key library than to figure out the particular magic incantation needed to get `ssh-keygen` to do it.</Sidenote> No problem, thought I, I'll just use `ssh-keygen` to convert the old format to a newer format! Unfortunately this was frustratingly<Sidenote>And needlessly, it seems to me?</Sidenote> difficult to figure out, so I'm writing it up here for posterity and so that I never have to look it up again.<Sidenote>You know how it works. Once you've taken the time to really describe process in detail, you have it locked in and never have to refer back to your notes.</Sidenote>
## Preamble: Fantastic Formats and Where to Find Them
I was aware, of course, that private keys are usually delivered as files containing big blobs of Base64-encoded text, prefaced by headers like `-----BEGIN OPENSSH PRIVATE KEY----`, and for whatever reason lacking file extensions.<Sidenote>Well, for the ones generated by `ssh-keygen`, at least. OpenSSL-generated ones often use `.key` or `.pem`, but those aren't typically used for SSH, so are less relevant here.</Sidenote> What I wasn't aware of is that there are actually several _different_ such formats, which although they look quite similar from the outside are internally pretty different. There are three you're likely to encounter in the wild:
1. OpenSSH-formatted keys, which start with `BEGIN OPENSSH KEY`<Sidenote>Plus the leading and trailing five dashes, but I'm tired of typing those out.</Sidenote> and are the preferred way of formatting private keys for use with SSH,
2. PCKS#8-formatted keys, which start with `BEGIN PRIVATE KEY` or `BEGIN ENCRYPTED PRIVATE KEY`, and
3. PEM or PKCS#1 format, which starts with `BEGIN RSA KEY`.
The oldest of these is PEM/PKCS#1 - the naming is a bit wishy-washy here. "PEM" when applied specifically to key files _for use with SSH_, i.e. the way that `ssh-keygen` uses it, seems to refer to this specific format. But "PEM" more generally is actually just a [generic container for binary data](https://en.wikipedia.org/w/index.php?title=Privacy-Enhanced_Mail&useskin=vector) that gets used a lot whenever it's helpful for binary data to be expressible as plaintext. In fact, _all_ of the private key formats I've ever seen used with OpenSSH<Sidenote>PuTTY does its own thing, which is why it's a major pain in the neck to convert between them, especially from the OpenSSH side. Fortunately [this is much less of a problem than it used to be.](https://github.com/PowerShell/Win32-OpenSSH?tab=readme-ov-file)</Sidenote> are some form of PEM file in the end.
Whatever you call it, however, this format has the major limitation that it can only handle RSA keys, which is why it fell out of favor when elliptic-curve cryptography started becoming more popular. The successor seems to have been PKCS#8, which is pretty similar but can hold other types of private keys. I haven't researched this one in quite as much detail, but I'm guessing that it also is a little nicer in how it handles encrypting private keys, since when you encrypt a PKCS#1 key it gets a couple of extra headers at the top, like this:
```
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,0B0A76ABB134DAFEB5C94C71760442EB
tOSEoYVcYcVEXl6TfBRjFRSihE3660NGRu692gAOqdYayozIvU9xpfeVCSlYO...
```
whereas when you encrypt a PKCS#8 private key the header just changes from `BEGIN PRIVATE KEY` to `BEGIN ENCRYPTED PRIVATE KEY` before starting in on the Base64 bit.
Both PKCS#1 and PKCS#8 use the same method for encoding the actual key data (which, when you get right down to it, is usually just a set of numbers with particular properties and relations between them): ASN.1, or Abstract Syntax Notation One. ASN.1 is... [complicated](https://en.wikipedia.org/wiki/ASN.1#Example). It seems very flexible, but it also seems like overkill unless you're trying to describe a very complex document, such as an X.509 certificate, or the legal code of the United States of America. But OpenSSH, it seems, longed for simpler days, when just reading a blasted private key didn't require pulling in a whole pile of parsing machinery, so it struck out on its own, and thereby was born the OpenSSH Private Key Format. This format does _not_ seem to be described in any RFCs, in fact the only detailed descriptions I can find are a [couple](https://coolaj86.com/articles/the-openssh-private-key-format/) of [blog posts](https://dnaeon.github.io/openssh-private-key-binary-format/) from people who figured it out for themselves from the OpenSSH source code.<Sidenote>Presumably this format is much simpler to parse and allows OpenSSH to do away with all the cumbersome drugery of dealing with ASN.1... or would, except that OpenSSH is still perfectly happy to read PKCS#1 and #8 keys. Maybe the eventual plan is to stop supporting the older formats? It's been 10 years, according to [another random blog post](https://www.thedigitalcatonline.com/blog/2021/06/03/public-key-cryptography-openssh-private-keys/), but maybe give it another 10 and we'll see some change?</Sidenote> I think this format can support every type of key that OpenSSH does, although I haven't personally confirmed that.<Sidenote>This is coming across as sarcastic, but I actually don't blame OpenSSH for coming up with its own private key format. If I had to deal with parsing ASN.1 I'd probably be looking for ways out too.</Sidenote>
## The `ssh-keygen` Manpage is a Tapestry of Lies
So, I thought, I can use `ssh-keygen` to convert between these various and sundry formats, right? It can do that, it _has_ to be able to do that, right?
Well, yes. It _can_, but good luck figuring out _how_. For starters, like many older CLI tools, `ssh-keygen` has an awful lot of flags and options, and it's hard to distinguish between which are _modifiers_ - "do the same thing, but differently" - and _modes of operation_ - "do a different thing entirely". The modern way to handle this distinction is with subcommands which take entirely different sets of arguments, but `ssh-keygen` dates back to a time before that was common.
It also dates back to a time when manpages were the primary way of communicated detailed documentation for CLI tools,<Sidenote>These days it seems more common to provide a reasonably-detailed `--help` output and then just link to web-based docs for more details.</Sidenote> which you'd _think_ would make it possible to figure out how to convert from one private key format to another, but oh-ho-ho! Not so fast, my friend. Here, feast your eyes on this:
```
-i This option will read an unencrypted private (or public) key file in the format specified by the -m option and print an
OpenSSH compatible private (or public) key to stdout. This option allows importing keys from other software, including
several commercial SSH implementations. The default import format is “RFC4716”.
```
Sounds great, right? Import private keys from other formats, i.e. convert them to our format, right? But it's _lying_. The `-i` mode doesn't accept private keys _at all_, that I've been able to tell, whatever their format. Giving it one will first prompt you for the passphrase, if any (so it's lying about needing an unencrypted input, although that's not a big deal) and then tell you bluntly that the your file is not in a valid format. The specific error message varies slightly with the particular format - attempting to give it a PEM file (with the appropriate option) returns `<file> is not a recognized public key format`, PKCS8 gets `unrecognised raw private key format`, and speicfy OpenSSH format just says `parse key: invalid format`. So really, the only thing this mode is useful for is reading a _public_ key in some PEM-ish format, and spitting out the line that you can used in `authorized_keys` - the one that starts with `ssh-rsa`, `ssh-ed25519`, etc.
## Enlightenment Ensues
But wait! The `-i` option mentions that the formats accepted are specified by the `-m` option, so let's take a look there:
```
-m key_format
Specify a key format for key generation, the -i (import), -e (export) conversion options, and the -p change passphrase
operation. The latter may be used to convert between OpenSSH private key and PEM private key formats. The supported
key formats are: “RFC4716” (RFC 4716/SSH2 public or private key), “PKCS8” (PKCS8 public or private key) or “PEM” (PEM
public key). By default OpenSSH will write newly-generated private keys in its own format, but when converting public
keys for export the default format is “RFC4716”. Setting a format of “PEM” when generating or updating a supported pri
vate key type will cause the key to be stored in the legacy PEM private key format.
```
Notice anything? I didn't, the first eleventy-seven times I read through this, because I was looking for a _list of known formats_, not a hint that you might be able to use _yet another option_ to do something _only marginally related to its core functionality_. So I missed that "The latter may be used to convert beteween OpenSSH private key and PEM private key<Sidenote>By PEM here OpenSSH apparently means both PKCS#1 _and_ PKCS#8, since it works perfectly well for both.</Sidenote> formats", and instead spent a while chasing my tail about RFC4716. This turns out to be not very helpful because it's _exclusively_ about a PEM-type encoding for _public_ keys, and doesn't mention private keys at all! OpenSSH seems to internally consider RFC4716 as the public-key counterpart to its home-grown private-key format, but this isn't explicitly laid out anywhere. Describing it as "RFC 4716/SSH2 public or private key" is confusing at best, because as we've established RFC 4716 doesn't mention private keys at all. "SSH2 private key" isn't obviously a thing: RFC 4716 public keys have the header `BEGIN SSH2 PUBLIC KEY`, but OpenSSH-formate private keys don't say anything about SSH2. The only way to figure out how it's being interpreted is to note that whenever `ssh-keygen` accepts the `-m` option _and_ happens to condescend to operate on private keys instead of public keys, giving it `-m RFC4716` produces and consumes private keys of the OpenSSH flavor.
_Anyway_. The documentation is so obtuse here that I didn't even discover this from the manpage at all, in the end. I had to get it from some random Github gist that I unfortunately can't find any more, but was probably written by someone just as frustrated as I am over the ridiculousness of this whole process. The _only_ way to change the format of a private key is to tell `ssh-keygen` that you want to _change its passphrase_? It's [git checkout](https://stevelosh.com/blog/2013/04/git-koans/#s2-one-thing-well) all over again.
At first I wondered whether maybe this is intentional, for security reasons, so that you don't accidentally remove the password from a private key while changing its format. But finally I don't think that makes sense, since if it's encrypted to start with then `ssh-keygen` is going to need its passphrase before it can make the conversion anyway, in which case there's no reason it can't just keep it hanging around and re-encrypt with the same passphrase after converting.
Most probably this is just a case of a tool evolving organically over time rather than being intentionally designed from the top down, and sure, that's understandable. Nobody sets out to create a tool that lies to you on its manpage<Sidenote>Maybe the `-i` option _did_ work with private keys at some point, although I have difficulty imagining why that functionality might have been removed.</Sidenote> and re-purposes modes of operation for other, only marginally-related operations, it just happens gradually over time because updates and new features are made in isolation, without consideration of the whole.
## Imagining a Brighter Tomorrow
But it doesn't have to be this way! Nothing (that I can see) prevents the `-i` option from being updated to accept private keys as well as public keys: it's clearly perfectly capable of telling when a file _isn't_ a valid _public_ key in the specified format, so it it seems like it could just parse it as a private key instead, and keep going if successful. Or an entirely new option could be added for converting private keys. `-c` is already taken for changing comments, but there are a few letters remaining. I don't see a `-j` on the manpage, for instance, or an `-x`.
I realize that an unforgiving reading of my travails in this endeavour might yield the conclusion that I'm an idiot with no reading comprehension, and that the manpage _clearly_ stated the solution to my problem _all along_, and if I had just RTFM<Sidenote>Noob.</Sidenote> then I could have avoided all this frustration, but that seems [a little unfair](https://xkcd.com/293/) to me.<Sidenote>Besides, I'm annoyed, and it's more satisfying to blame others than admit any fault of my own.</Sidenote> When you're writing the help message or manpage for your tool, you should _expect_ that people will be skimming it, looking to pick out the tidbits that are important to them right now, since for any tool of reasonable complexity 95% of the documentation is going to be irrelevant to any single user in any single situation.
How could the manpage be improved? Well, for starters, it could _not lie_ about the fact that the `-i` option doesn't do anything with private keys at all. If it were _really_ trying to be helpful it could even throw in a hint that if you want to work with private keys, you're barking up the wrong tree. Maybe it could say "Note: This option only accepts public keys. For private key conversions see -p and -m." or something like that. The `-p` option should probably also mention somewhere in its description that it happens to also be the preferred method for converting formats, since right now it only talks about changing passphrases.
Anyway, thanks for listening to my TED talk. At least now I'll never forget how to convert a private key again.

View File

@ -1,23 +0,0 @@
---
title: The Enduring Shell
date: 2023-11-26
draft: true
---
<script>
import Sidenote from '$lib/Sidenote.svelte';
</script>
Over twenty years ago, Neal Stephenson wrote an essay/pamphlet/novella/whatever-you-want-to-call-it titled [_In the beginning was the Command Line_](http://project.cyberpunk.ru/lib/in_the_beginning_was_the_command_line/). It's worth reading, and you should definitely do that at some point, but you should finish this first because it's quite long and Neal Stephenson is a much better writer than I am, so I worry you wouldn't come back.<Sidenote>I should probably also mention that it's Stephenson at his, ah, least restrained, so it's rather meandering. Don't get me wrong, it's _well-written_ meandering, but I don't think you can argue that an essay about command lines isn't meandering when it includes a thousand-word segment about Disney World.</Sidenote> As you might expect, Stephenson spends a lot of that material talking about the CLI versus the GUI, as though they were opposite poles of some abstract computational magnet. It's been a while since I read it, but I distinctly remember him describing the advent of the GUI as a sort of impending inevitability, an unfortunate but unstoppable end to which all things in time will eventually come. It's a little like watching [_Valkyrie_](https://www.imdb.com/title/tt0985699/), actually--you know whe whole time how it's going to turn out, but you can't keep yourself from watching it anyway.
The impending doom in this case is the ultimate triumph of the GUI over the CLI. Reading Stephenson's essay, you would be excused in coming away with the impression that the GUI is the way of the future, and that the CLI will eventually be relegated to the status of a quaint, old-timey practice and fall out of use except as a curiosity.<Sidenote>This isn't the only place I've run across this line of thought, either. David Edelman's [Jump 225 trilogy](https://www.goodreads.com/series/45075-jump-225) is set in a world where programming is no longer text-based but accomplished by manipulating a 3-dimensional model of the program; the programmer's tools are a set of physical instruments that he uses to maniuplate the program-model in various ways.</Sidenote>
He might have been surprised, then<Sidenote>He's still alive, I guess I could just ask him.</Sidenote> if he had known that today, in the far-distant future of 2023, many people (mostly technical people, it is to be admitted) use the command line every day, and that in some ways it's more alive and well than it ever has been. It's still not the dominant paradigm of computer interfaces for most people of course, and never will be again--that ship has most definitely sailed. But at the same time it's not going away any time soon, because there are aspects of the CLI that make it _better_ than a GUI for many uses.
A long time ago, the first time I needed to encode or transcode a video, I [downloaded Handbrake](https://handbrake.fr/downloads2.php).<Sidenote>I'm pretty sure the download page looked exactly the same then as it does now (except for the cookie warning, of course). It's nice that there are a few islands of stability in the sea of change that is the Internet.</Sidenote> I think I had read about it on Lifehacker, back when Lifehacker was good. I remember at the time being vaguely surprised that it came in both GUI and CLI flavors,<Sidenote>And you can thank Microsoft for that, as they have in their infinite wisdom decided that a given executable should function as either a CLI app or a GUI app, but never, ever be permitted to do both.</Sidenote> since it had never occurred to me for even the barest moment that I might want to use Handbrake via anything other than a GUI.
A lot of time has passed since then, and now I can easily imagine situations where I'd want the CLI version of Handbrake rather than the GUI. So what are those situations? What is it about the CLI that has kept it hanging around all these years, hanging on grimly by its fingertips in some cases, while generation after generation of graphical whizmos have come and gone? There are a number of reasons, I think.
## CLI apps are easier to write
blah blah words here

View File

@ -4,21 +4,22 @@ description: They're more similar than they are different, but they say the most
date: 2023-06-29
---
<script>
import Dropcap from '$lib/Dropcap.svelte';
import Sidenote from '$lib/Sidenote.svelte';
</script>
Recently I've had a chance to get to know Vue a bit. Since my frontend framework of choice has previously been Svelte (this blog is built in Svelte, for instance) I was naturally interested in how they compared.
<Dropcap word="Recently">I've had a chance to get to know Vue a bit. Since my frontend framework of choice has previously been Svelte (this blog is built in Svelte, for instance) I was naturally interested in how they compared.</Dropcap>
This is necessarily going to focus on a lot of small differences, because Vue and Svelte are really much more similar than they are different. Even among frontend frameworks, they share a lot of the same basic ideas and high-level concepts, which means that we get to dive right into the nitpicky details and have fun debating `bind:attr={value}` versus `:attr="value"`. In the meantime, a lot of the building blocks are basically the same or at least have equivalents, such as:
Of course, this is only possible because Vue and Svelte are really much more similar than they are different. Even among frontend frameworks, they share a lot of the same basic ideas and high-level concepts, which means that we get to dive right into the nitpicky details and have fun debating `bind:attr={value}` versus `:attr="value"`. In the meantime, a lot of the building blocks are basically the same or at least have equivalents, such as:
* Single-file components with separate sections for markup, style, and logic
* Automatically reactive data bindings
* Two-way data binding (a point of almost religious contention in certain circles)
* An "HTML-first" mindset, as compared to the "Javascript-first" mindset found in React and its ilk. The best way I can describe this is by saying that in Vue and Svelte, the template wraps the logic, whereas in React, the logic wraps the template.
* An "HTML-first" mindset, as compared to the "Javascript-first" mindset found in React and its ilk. The best way I can describe this is by saying that in Vue and Svelte, the template<Sidenote>Or single-file component, anyway.</Sidenote> embeds the logic, whereas in React, the logic embeds the template.
I should also note that everything I say about Vue applies to the Options API unless otherwise noted, because that's all I've used. I've only seen examples of the Composition API (which looks even more like Svelte, to my eyes), I've never used it myself.
With that said, there are plenty of differences between the two, and naturally I find myself in possession of immediate and vehement Preferences.<Sidenote>Completely arbitrary, of course, so feel free to disagree!</Sidenote> Starting with:
With that said, there are plenty of differences between the two, and naturally I find myself in possession of immediate and vehement Preferences.<Sidenote>I should also clarify that practically everything in this post is just that: a preference. While I obviously plan to explain my preferences and think it would be reasonable for other people to do the same, it's undeniably true that preferences can vary, and in a lot of cases are basically arbitrary. So if you find yourself disagreeing with all or most of what I say, consider it an opportunity to peer into the mindset of The Other Side.</Sidenote> Starting with:
## Template Syntax
@ -52,11 +53,11 @@ While Svelte takes the more common approach of wrapping bits of markup in its ow
</div>
```
While Vue's approach may be a tad unorthodox, I find that I actually prefer it in practice. It has the killer feature that, by embedding itself inside the existing HTML, it doesn't mess with my indentation - which is something that has always bugged me about Mustache, Liquid, Jinja, etc.<Sidenote>Maybe it's silly of me to spend time worrying about something so trivial, but hey, this whole post is one big bikeshed anyway.</Sidenote>
While Vue's approach may be a tad unorthodox, I find that I actually prefer it in practice. It has the killer feature that, by embedding itself inside the existing HTML, it doesn't mess with my indentation - which is something that has always bugged me about Mustache, Liquid, Jinja, etc.<Sidenote>Maybe it's silly of me to spend time worrying<Sidenote>Nested<Sidenote>Doubly-nested sidenote!</Sidenote> sidenote!</Sidenote> about something so trivial,<Sidenote>Second nested sidenote.</Sidenote> but hey, this whole post is one big bikeshed anyway.</Sidenote>
Additionally (and Vue cites this as the primary advantage of its style, I think) the fact that Vue's custom attributes are all syntactically valid HTML means that you can actually embed Vue templates directly into your page source. Then, when you mount your app to an element containing Vue code, it will automatically figure out what to do with it.<Sidenote>AlpineJS also works this way, but this is the *only* way that it works - it doesn't have an equivalent for Vue's full-fat "app mode" as it were.</Sidenote> This strikes me as a fantastic way to ease the transition between "oh I just need a tiny bit of interactivity on this page, so I'll just sprinkle in some inline components" and "whoops it got kind of complex, guess I have to factor this out into its own app with a build step and all now."
Detractors of this approach might point out that it's harder to spot things like `v-if` and `v-for` when they're hanging out inside of existing HTML tags, but that seems like a problem that's easily solved with a bit of syntax highlighting. However I do have to admit that it's a reversal of the typical order in which you read code: normally you see the control-flow constructs _first_, and only _after_ you've processed those do you start to worry about whatever they're controlling. So you end up with a sort of [garden-path-like](https://xkcd.com/2793/) problem where you have to mentally double back and re-read things in a different light. I still don't think it's a huge issue, though, because in every case I'm come across the control flow bits (so `v-if`, `v-for`, and `v-show`) are specified _immediately_ after the opening tag. So you don't really have to double back by an appreciable amount, and it doesn't take too long to get used to it.
Detractors of this approach might point out that it's harder to spot things like `v-if` and `v-for` when they're hanging out inside of existing HTML tags, but that seems like a problem that's easily solved with a bit of syntax highlighting.<Sidenote>I'm being unfair here. It's more than just a lack of syntax highlighting, it's a reversal of the typical order in which people are used to reading code, where the control flow is indicated before whatever it's controlling. So you end up with a sort of [garden-path-like](https://xkcd.com/2793/) problem where you have to mentally double back and re-read things in a different light. I still don't think it's a huge issue, though, because in every case I'm come across the control flow bits (so `v-if`, `v-for`, and `v-show`) are specified _immediately_ after the opening tag. So you don't really have to double back by an appreciable amount, and it doesn't take too long to get used to it.</Sidenote>
Continuing the exploration of template syntax, Vue has some cute shorthands for its most commonly-used directives, including `:` for `v-bind` and `@` for `v-on`. Svelte doesn't really have an equivalent for this, although it does allow you to shorten `attr={attr}` to `{attr}`, which can be convenient. Which might as well bring us to:
@ -66,11 +67,11 @@ I give this one to Svelte overall, although Vue has a few nice conveniences goin
Something that threw me a tiny bit when I first dug into Vue was that you need to use `v-bind` on any attribute that you want to have a dynamic value. So for instance, if you have a data property called `isDisabled` on your button component, you would do `<button v-bind:disabled="isDisabled">` (or the shorter `<button :disabled="isDisabled">`).
The reason this threw me is that Svelte makes the very intuitive decision that since we already have syntax for interpolating variables into the text contents of our markup, we can just reuse the same syntax for attributes. So the above would become `<button disabled={isDisabled}>`, which I find a lot more straightforward.<Sidenote>If your interpolation consists of a single expression you can even leave off the quote marks (as I did here), which is pleasant since you already have `{}` to act as visual delimiters.</Sidenote> I also find it simpler in cases where you want to compose a dynamic value out of some fixed and some variable parts, e.g. `<button title="Save {itemsCount} items">` vs. `<button :title="&#96;Save ${itemsCount} items&#96;">`.
The reason this threw me is that Svelte makes the very intuitive decision that since we already have syntax for interpolating variables into the text contents of our markup, we can just reuse the same syntax for attributes. So the above would become `<button disabled={isDisabled}>`, which I find a lot more straightforward.<Sidenote>If your interpolation consists of a single expression you can even leave off the quote marks (as I did here), which is pleasant since you already have `{}` to act as visual delimiters.</Sidenote> I also find it simpler in cases where you want to compose a dynamic value out of some fixed and some variable parts, e.g. `<button title="Save {{itemsCount}} items">` vs. `<button :title="&#96;Save ${itemsCount} items&#96;">`.
Two-way bindings in Svelte are similarly straightforward, for example: `<input type="checkbox" bind:checked={isChecked}>` In Vue this would be `<input type="checkbox" v-model="isChecked">`, which when you first see it doesn't exactly scream that the value of `isChecked` is going to apply to the `checked` property of the checkbox. On the other hand, this does give Vue the flexibility of doing special things for e.g. the values of `<select>` elements: `<select v-model="selectedOption">` is doing quite a bit of work, since it has to interact with not only the `<select>` but the child `<option>`s as well. Svelte just throws in the towel here and tells you to do `<select bind:value={selectedOption}>`, which looks great until you realize that `value` isn't technically a valid attribute for a `<select>`. So Svelte's vaunted principle of "using the platform" does get a _little_ bent out of shape here.
Oh, and two-way bindings in Vue get _really_ hairy if it's another Vue component whose attribute you want to bind, rather than a built-in form input. Vue enforces that props be immutable from the inside, i.e. a component isn't supposed to mutate its own props. So from the parent component it doesn't look too bad:
Oh, and two-way bindings in Vue get _really_ hairy if it's another Vue component whose attribute you want to bind, rather than a builtin form input. Vue enforces that props be immutable from the inside, i.e. a component isn't supposed to mutate its own props. So from the parent component it doesn't look too bad:
```markup
<ChildComponent v-model="childValue" />`
```
@ -87,9 +88,9 @@ export default {
}
```
In Svelte, you just `bind:` on a prop of a child component, and then if the child updates the prop it will be reflected in the parent as well. I don't think there's any denying that's a lot simpler.<Sidenote>I think this is where the "two-way data binding" holy wars start to get involved, but I actually really like the way Svelte does things here. I think most of the furor about two-way data binding refers to bindings that are _implicitly_ two-way, i.e. anyone with a reference to some value can mutate it in ways the original owner didn't expect or intend it to. (KnockoutJS observables work this way, I think?) In Svelte's case, though, this is only possible if you explicitly pass the state with `bind:`, which signifies that you _do_ want this state to be mutated by the child and that you have made provisions therefor. My understanding is that in React you'd just be emitting an event from the child component and handling that event up the tree somewhere, so in practice it's basically identical. That said, I haven't used React so perhaps I'm not giving the React Way™ a fair shake here.</Sidenote>
In Svelte, you just `bind:` on a prop of a child component, and then if the child updates the prop it will be reflected in the parent as well. I don't think there's any denying that's a lot simpler.<Sidenote>I think this is where the "two-way data binding" holy wars start to get involved, but I actually really like the way Svelte does things here. I think most of the furor about two-way data binding refers to bindings that are _implicitly_ two-way, i.e. the child can mutate state that the parent didn't expect or intend it to. In Svelte's case, though, this is only possible if you explicitly pass the state with `bind:`, which signifies that you _do_ want this state to be mutated by the child and that you have made provisions therefor. </Sidenote>
Vue does have some lovely convenience features for common cases, though. One of my favorites is binding an object to the `class` of an HTML element, for example: `<button :class="{btn: true, primary: false}">` Which doesn't look too useful on its own, but move that object into a data property and you can now toggle classes on the element extremely easily by just setting properties on the object. The closest Svelte comes is `<button class:btn={isBtn} class:primary={isPrimary}>`, which is a lot more verbose. Vue also lets you bind an array to `class` and the elements of the array will be treated as individual class names, which can be convenient in some cases if you have a big list of classes and you're toggling them all as a set. <Sidenote>Since I'm a fan of TailwindCSS, this tends to come up for me with some regularity.</Sidenote>
Vue does have some lovely convenience features for common cases, though. One of my favorites is binding an object to the `class` of an HTML element, for example: `<button :class="{btn: true, primary: false}">` Which doesn't look too useful on its own, but move that object into a data property and you can now toggle classes on the element extremely easily by just setting properties on the object. The closest Svelte comes is `<button class:btn={isBtn} class:primary={isPrimary}>`, which is a lot more verbose. Vue also lets you bind an array to `class` and the elements of the array will be treated as individual class names, which can be convenient in some cases if you have a big list of classes and you're toggling them all as a set.
The other area where I vastly prefer Vue's approach over Svelte's is in event handlers. Svelte requires that every event handler be a function, either named or inline, so with simple handlers you end up with a lot of `<button on:click={() => counter += 1}` situations. Vue takes the much more reasonable approach of letting you specify a plain statement as your event handler, e.g. `<button @click="counter += 1">`. For whatever reason this has always particularly annoyed me about Svelte, so Vue's take is very refreshing.
@ -106,7 +107,7 @@ You really only need to access the event when you're doing something more exotic
In Vue, reactive values (by which I mean "values that can automatically trigger a DOM update when they change") are either passed in as `props`, or declared in `data`. Or derived from either of those sources in `computed`. Then you reference them, either directly in your template or as properties of `this` in your logic. Which works fine, more or less, although you can run into problems if you're doing something fancy with nested objects or functions that get their own `this` scope.<Sidenote>It's worth noting that the Composition API avoids this, at the cost of having to call `ref()` on everything and reference `reactiveVar.value` rather than `reactiveVar` by itself.</Sidenote> The split between how you access something from the template and how you access it from logic was a touch surprising to me at first, though.
In Svelte, variables are just variables, you reference them the same way from everywhere, and if they need to be reactive it (mostly) just happens automagically.<Sidenote>And of course, after I first wrote this but just before I was finally ready to publish, Svelte went ahead and [changed this on me](https://svelte.dev/blog/runes). I'll leave my comments here as I originally wrote them, just keep in mind that if these changes stick then Svelte becomes even _more_ similar to Vue's composition API.</Sidenote> Svelte has a lot more freedom here because it's a compiler, rather than a library, so it can easily insert calls to its special `$$invalidate()` function after any update to a value that needs to be reactive.
In Svelte, variables are just variables, you reference them the same way from everywhere, and if they need to be reactive it (mostly) just happens automagically. Svelte has a lot more freedom here because it's a compiler, rather than a library, so it can easily insert calls to its special `$$invalidate()` function after any update to a value that needs to be reactive.
Both frameworks allow you to either derive reactive values from other values, or just execute arbitrary code in response to data updates. In Vue these are two different concepts - derived reactive values are declared in `computed`, and reactive statements via the `watch` option. In Svelte they're just the same thing: Prefix any statement with `$:` (which is actually valid JS, as it turns out) and it will automatically be re-run any time one of the reactive values that it references gets updated. So both of the following:
```js
@ -123,7 +124,7 @@ $: console.log(firstname, lastname);
I go back and forth on this one, but I _think_ I have a slight preference for Svelte (at least, at the moment.) The major difference is that Vue<Sidenote>If you're using the Options API, at least.</Sidenote> enforces a lot more structure than Svelte: Data is in `props`/`data`/`computed`, logic is in `methods`, reactive stuff is in `watch`, etc. Svelte, by contrast, just lets you do basically whatever you want. It does require that you have only one `<script>` tag, so all your logic ends up being co-located, but that's pretty much it. Everything else is just a convention, like declaring props at the top of your script.
The advantage of Vue's approach is that it can make it easier to find things when you're jumping from template to logic: you see `someFunction(whatever)`, you know it's going to be under `methods`. With Svelte, `someFunction` could be defined anywhere in the script section.<Sidenote>Code structure is actually one area that I think might be improved by the recently-announced Svelte 5 changes: Because you can now declare reactive state anywhere, rather than just at the top level of your script, you can take all the discrete bits of functionality within a single component and bundle each one up in its own function, or even factor them out into different files entirely. I can imagine this being helpful, but I haven't played with it yet so I don't know for sure how it will shake out.</Sidenote>
The advantage of Vue's approach is that it can make it easier to find things when you're jumping from template to logic: you see `someFunction(whatever)`, you know it's going to be under `methods`. With Svelte, `someFunction` could be defined anywhere in the script section.
On the other hand, this actually becomes a downside once your component gets a little bit complex. Separation of concerns is nice and all, but sometimes it just doesn't work very well to split a given component, and it ends up doing several unrelated or at least clearly distinct things. In Vue-land, the relevant bits of state, logic, etc. are all going to be scattered across `data`/`methods`/etc, meaning you can't really see "all the stuff that pertains to this one bit of functionality" in one place. It's also very clunky to split the logic for a single component across multiple JS files, which you might want to do as another way of managing the complexity of a large component. If you were to try, you'd end up with a big "skeleton" in your main component file, e.g.
@ -145,11 +146,11 @@ export default {
which doesn't seem very pleasant.
As a matter of fact, this was one of the primary [motivations](https://web.archive.org/web/20201109010309/https://composition-api.vuejs.org/#logic-reuse-code-organization)<Sidenote>Archive link, since that url now redirects to the [current Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html).</Sidenote> for the introduction of the Composition API in the first place. Unfortunately it also includes the downside that you have to call `ref()` on all your reactive values, and reference them by their `.value` property rather than just using the main variable. It's funny that this bothers me as much as it does, given that `this.someData` is hardly any more concise than `someData.value`, but there's no accounting for taste, I guess. Using `this` just feels more natural to me, although what feels most natural is Svelte's approach where you don't have to adjust how you reference reactive values at all.
As a matter of fact, this was one of the primary [motivations](https://web.archive.org/web/20201109010309/https://composition-api.vuejs.org/#logic-reuse-code-organization) for the introduction of the Composition API in the first place.<Sidenote>Archive link, since that url now redirects to the [current Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html).</Sidenote> Unfortunately it also includes the downside that you have to call `ref()` on all your reactive values, and reference them by their `.value` property rather than just using the main variable. It's funny that this bothers me as much as it does, given that `this.someData` is hardly any more concise than `someData.value`, but there's no accounting for taste, I guess. Using `this` just feels more natural to me, although what feels most natural is Svelte's approach where you don't have to adjust how you reference reactive values at all.
Also, as long as we're harping on minor annoyances: For some reason I cannot for the life of me remember to put commas after all my function definitions in `computed`, `methods` etc. in my Vue components. It's such a tiny thing, but it's repeatedly bitten me because my workflow involves Vue automatically rebuilding my app every time I save the file, and I'm not always watching the console output because my screen real estate is in use elsewhere.<Sidenote>E.g. text editor on one screen with two columns of text, web page on one half of the other screen and dev tools on the other half. Maybe I need a third monitor?</Sidenote> So I end up forgetting a comma, the rebuild fails but I don't notice, and then I spend five minutes trying to figure out why my change isn't taking effect before I think to check for syntax errors.
It would be remiss of me, however, not to point out that one thing the Vue Options API enables<Sidenote>Kind of its initial _raison d'être_, from what I understand.</Sidenote> which is more or less impossible<Sidenote>I mean, you could do it, but you'd have to ship the entire Svelte compiler with your page.</Sidenote> with Svelte is at-runtime or "inline" components, where you just stick a blob of JS onto your page that defines a Vue component and where it should go, and Vue does the rest on page load. Svelte can't do this because it's a compiler, so naturally it has to compile your components into a usable form. This has many advantages, but sometimes you don't want to or even _can't_ add a build step, and in those cases Vue can really shine.
It would be remiss of me, however, not to point out that one thing the Vue Options API enables<Sidenote>Kind of its initial _raison d'être_, from what I understand.</Sidenote> which is completely impossible with Svelte is at-runtime or "inline" components, where you just stick a blob of JS onto your page that defines a Vue component and where it should go, and Vue does the rest on page load. Svelte can't do this because it's a compiler, so naturally it has to compile your components into a usable form. This has many advantages, but sometimes you don't want to or even _can't_ add a build step, and in those cases Vue can really shine.
## Miscellany
@ -157,20 +158,8 @@ It would be remiss of me, however, not to point out that one thing the Vue Optio
Performance isn't really a major concern for me when it comes to JS frameworks, since I don't tend to build the kind of extremely-complex apps where the overhead of the framework starts to make a difference. For what it's worth, though, the [Big Benchmark List](https://krausest.github.io/js-framework-benchmark/current.html) has Vue slightly ahead of Svelte when it comes to speed.<Sidenote>Although [recent rumors](https://twitter.com/Rich_Harris/status/1688581184018583558) put the next major version of Svelte _very_ close to that of un-framework'd vanilla JS, so this might change in the future.</Sidenote> I don't know how representative this benchmark is of a real-world workload.
As far as bundle size goes, it's highly dependent on how many components you're shipping - since Svelte compiles everything down to standalone JS and there's no shared framework, the minimum functional bundle can be quite small indeed. The flipside is that it grows faster with each component than Vue, again because there's no shared framework to rely on. So a Svelte app with 10 components will probably be a lot smaller than the equivalent Vue app, but scale that up to 1000 components and the advantage will most likely have flipped. The Svelte people say that this problem doesn't tend to crop up a lot in practice, but I have yet to see real-world examples for the bundle size of a non-trivial<Sidenote>Probably because no one wants to bother implementing the exact same app in two different frameworks just to test a theory.</Sidenote> app implemented in Vue vs. Svelte.
As far as bundle size goes, it's highly dependent on how many components you're shipping - since Svelte compiles everything down to standalone JS and there's no shared framework, the minimum functional bundle can be quite small indeed. The flipside is that it grows faster with each component than Vue, again because there's no shared framework to rely on. So a Svelte app with 10 components will probably be a lot smaller than the equivalent Vue app, but scale that up to 1000 components and the advantage will most likely have flipped.
### Ecosystem
Vue has been around longer than Svelte, so it definitely has the advantage here. That said, Svelte has been growing pretty rapidly in recent years and there is a pretty decent ecosystem these days. This blog, for instance, uses [SvelteKit](https://kit.svelte.dev) and [mdsvex](https://mdsvex.pngwn.io/). But there are definitely gaps, e.g. I wasn't able to find an RSS feed generator when I went looking.<Sidenote>Arguably this is a lack in the SvelteKit ecosystem rather than the Svelte ecosystem, but I think it's fair to lump it together. SvelteKit is dependent on Svelte, so naturally it inherits all of Svelte's immaturity issues plus more of its own.</Sidenote> If I'd been using Vue/Nuxt it would have been available as a [first-party integration](https://content.nuxtjs.org/v1/community/integrations). All in all I'd say if a robust ecosystem is important to you then Vue is probably the better choice at this point.
### Stability
Not in terms of "will it crash while you're using it," but in terms of "will code that you write today still be usable in five years." This is always a bit of a big ask in the JS world, because everyone is always pivoting to chase the new shiny. As I write this now (and as I referenced above), Svelte has just announced a [change](https://svelte.dev/blog/runes) to how reactivity is done. The new style is opt-in for the moment, but that's never completely reassuring--there are plenty of examples of opt-in features that became required eventually. Vue had a similar moment with their 2-to-3 switch,<Sidenote>Just like Python, hmm. What is it about the 2-to-3 transition? Maybe we should call it Third System Effect?</Sidenote> but to be fair they have so far stuck to their promise to keep the Options API a first-class citizen.
I think that means I have to give Vue the edge on this one, because while both frameworks now have an "old style" vs. a "new style" Vue at least has proven their willingness to continue supporting the old style over the last few years.
## What's Next
I don't think we've reached the "end-game" when it comes to UI paradigms, either on the web or more generally. I _do_ think that eventually, _probably_ within my lifetime, we will see a stable and long-lasting consensus emerge, and the frenetic pace of "framework churn" in the frontend world will slow down somewhat. What exact form this will take is very much up in the air, of course, but I have a sneaking suspicion that WebAssembly will play a key part, if it can ever get support for directly communicating with the DOM (i.e. without needing to pass through the JS layer). _If_ and when that happens, it will unlock a huge new wave of frontend frameworks that don't have to involve on Javascript at all, and won't that be interesting?
But for now I'll stick with Svelte, although I think Vue is pretty good too. Just don't make me use React, please.

View File

@ -1,27 +0,0 @@
<script>
import '$styles/prose.scss';
</script>
<style>
.content {
max-width: var(--content-width);
margin: 0 auto;
}
</style>
<svelte:head>
<title>About Me | Joe's Blog</title>
</svelte:head>
<div class="prose content">
<h1>About Me</h1>
<p>(Joe's wife wrote this because Joe feels weird writing about himself.)</p>
<p>Joe is a quirky, techy Tolkienite with a beautiful singing voice, an uncanny ability to do mental math, a bony, un-cuddleable frame, and a big mushy heart. He enjoys bike riding, computers, watching TV, reading about computers, playing Breath of the Wild, building computers, talking about something called "programming languages", and spending time with his family (which often involves fixing their computers). He graduated with a Liberal Arts degree from Thomas Aquinas College, the school of his forebears. He often remarks that he has greatly benefitted from the critical thinking skills he acquired at his alma mater in his current line of work.</p>
<p>He has spent, at the current time, about 2 years working on this blog. Most of his posts are about all of the work it took and everything he learned making this blog. Unlike most "bloggers", he has started with many blog posts and no blog, rather than a blog without posts. "Someday", he says, "I will actually get that blog up". I always nod encouragingly.</p>
<p>If you are reading this, then that day has arrived. We hope you enjoy it, and maybe even learn something along the way.</p>
</div>

View File

@ -1,53 +0,0 @@
import { tag, text, serialize } from '$lib/xml.js';
import { postData } from '../_posts/all.js';
export const prerender = true;
export function GET() {
return new Response(renderFeed(), {
headers: {'Content-Type': 'application/atom+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', {rel: 'alternate', href: 'https://blog.jfmonty2.com/'});
feed.addTag('link', {rel: 'self', href: 'https://blog.jfmonty2.com/feed/'});
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 url = `https://blog.jfmonty2.com/${post.slug}`
const entry = feed.addTag('entry');
entry.addTag('title', {}, [text(post.title)]);
entry.addTag('link', {rel: 'alternate', href: url});
entry.addTag('id', {}, [text(url)]);
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();
}

18
src/routes/index.svelte Normal file
View File

@ -0,0 +1,18 @@
<script context="module">
export async function load({ fetch }) {
const resp = await fetch('/latest.json');
const postMeta = await resp.json();
const post = await import(`./_posts/${postMeta.slug}.svx`);
return {
props: {
post: post.default,
}
}
}
</script>
<script>
export let post;
</script>
<svelte:component this={post} />

View File

@ -0,0 +1,5 @@
import { postData } from './posts.js';
export async function get() {
return {body: postData[0]};
}

View File

@ -1,26 +1,28 @@
import { dev } from '$app/environment';
const posts = import.meta.globEager('./*.svx');
import { dev } from '$app/env';
const posts = import.meta.globEager('./_posts/*.svx');
export let postData = [];
let postData = [];
for (const path in posts) {
// skip draft posts in production mode
if (!dev && posts[path].metadata.draft) {
continue;
}
// slice off the ./ and the .svx
const slug = path.slice(2, -4);
const slug = path.slice(9, -4)
posts[path].metadata.slug = slug;
postData.push(posts[path].metadata);
}
postData.sort((a, b) => {
// sorting in reverse, so we flip the intuitive order
if (a.date > b.date) return -1;
if (a.date < b.date) return 1;
return 0;
});
})
export async function get() {
return {
body: {postData}
};
}
export { postData };

73
src/routes/posts.svelte Normal file
View File

@ -0,0 +1,73 @@
<script>
import { formatDate } from '$lib/datefmt.js';
export let postData;
</script>
<style lang="scss">
#posts {
/*text-align: center;*/
max-width: 24rem;
// margin-top: 1.25rem;
margin-left: auto;
margin-right: auto;
}
.post {
border-bottom: 2px solid #eee;
margin-top: 1rem;
}
/* .post-title {
font-weight: bold;
font-size: 1.2rem;
}*/
.post-date {
color: #808080;
}
.draft-notice {
vertical-align: 0.3rem;
font-size: 0.6rem;
padding: 0 0.3rem;
color: #e00;
background-color: #ffd9d9;
border: 1px solid red;
border-radius: 20%/50%;
margin: 0 0.2rem;
}
.post-link {
text-decoration: none;
}
.post-link:hover {
text-decoration: underline;
}
h3 {
display: inline;
margin: 0;
}
</style>
<svelte:head>
<title>Posts</title>
</svelte:head>
<div id="posts">
<h1 style:text-align="center">All Posts</h1>
{#each postData as post}
<div class="post">
<div class="post-date">{new Date(post.date).toISOString().split('T')[0]}</div>
<div>
<a sveltekit:prefetch class="post-link" href="/{post.slug}">
<h3>{post.title}<h3>
</a>
{#if post.draft}
<span class="draft-notice">Draft</span>
{/if}
</div>
<p>{post.description}</p>
</div>
{/each}
</div>

View File

@ -1,86 +0,0 @@
<script>
import '$styles/prose.scss';
import { formatDate } from '$lib/datefmt.js';
import { postData } from '../_posts/all.js';
</script>
<style lang="scss">
.wrapper {
padding: 0 var(--content-padding);
}
.posts {
max-width: var(--content-width);
margin: 0 auto;
}
hr {
margin: 2.5rem 0;
border-color: #eee;
}
.post-date {
color: var(--content-color-faded);
}
.draft-notice {
vertical-align: middle;
font-size: 0.75rem;
padding: 0 0.3rem;
color: #e00;
background-color: #ffd9d9;
border: 1px solid red;
border-radius: 20% / 50%;
}
.post-link {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
h2 {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
margin-bottom: 0.75rem;
font-size: 1.5rem;
& a {
color: currentcolor;
}
}
</style>
<svelte:head>
<title>Posts</title>
</svelte:head>
<div class="wrapper">
<div class="posts prose">
<h1 style:text-align="center">All Posts</h1>
{#each postData as post, idx}
<div class="post">
<div class="post-date">{new Date(post.date).toISOString().split('T')[0]}</div>
<h2 class="prose">
<a data-sveltekit-preload-data="hover" class="post-link" href="/{post.slug}">
{post.title}
</a>
{#if post.draft}
<span class="draft-notice">Draft</span>
{/if}
</h2>
<p>{post.description}</p>
</div>
{#if idx < postData.length - 1}
<hr>
{/if}
{/each}
</div>
</div>

View File

@ -1,27 +0,0 @@
@import 'prism-dracula';
@font-face {
font-family: 'Hack';
font-style: normal;
font-weight: 400;
src: url(/Hack-Regular.woff2) format('woff2');
font-display: block;
}
code {
padding: 0.05rem 0.2rem 0.1rem;
background: #eee;
border-radius: 0.2rem;
font-size: 0.75em;
font-family: 'Hack', monospace;
}
pre[class*="language-"] {
line-height: 1.25;
}
pre > code[class*="language-"] {
font-size: 0.75em;
font-family: 'Hack', monospace;
}

View File

@ -1,39 +0,0 @@
@import 'reset';
@font-face {
font-family: 'Tajawal';
font-style: normal;
font-weight: 400;
src: url(/Tajawal-Regular.woff2) format('woff2');
font-display: block;
}
:root {
--content-size: 1.25rem;
--content-size-sm: 1rem;
--content-line-height: 1.4;
--content-width: 52.5rem;
--content-padding: 0.65rem;
--content-color: #1e1e1e;
--content-color-faded: #555;
--primary-color: hsl(202deg 72% 28%);
--primary-color-faded: hsl(202deg 14% 36%);
--accent-color: hsl(0deg, 92%, 29%);
--accent-color-faded: hsl(0deg, 25%, 55%);
@media(max-width: 640px) {
--content-line-height: 1.25;
--content-size: 1.15rem;
--content-size-sm: 0.9rem;
}
}
body {
font-family: 'Tajawal', sans-serif;
font-size: var(--content-size);
line-height: var(--content-line-height);
letter-spacing: -0.005em;
color: var(--content-color);
}

View File

@ -1,51 +0,0 @@
.prose {
h1, h2, h3, h4, h5, h6 {
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Ubuntu, Arial, sans-serif;
font-weight: 600;
color: #464646;
}
h1 {
margin-top: 0.5em;
font-size: 2em;
font-variant: petite-caps;
}
h2 {
font-size: 1.5em;
}
h3 {
font-size: 1.2em;
}
h4 {
font-size: 1.1em;
}
h1, h2, h3, h4 {
margin-bottom: 0.5em;
}
p, ul, ol {
margin-bottom: 0.8em;
}
ul, ol, blockquote {
padding: 0;
margin-left: 2em;
}
blockquote {
position: relative;
font-style: italic;
}
blockquote::before {
content: '';
position: absolute;
left: -01em;
height: 100%;
border-right: 3px solid var(--accent-color);
}
}

View File

@ -1,22 +0,0 @@
// This reset lifted largely from Josh Comeau's "CSS for JS Devs" course
// Use a more-intuitive box-sizing model.
*, *::before, *::after {
box-sizing: border-box;
}
// Remove default margin
* {
margin: 0;
}
// Allow percentage-based heights in the application
html, body {
min-height: 100%;
}
// Improve media defaults
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}

Binary file not shown.

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

91
static/style.css Normal file
View File

@ -0,0 +1,91 @@
/* ### TYPOGRAPHY ### */
@font-face {
font-family: 'Tajawal';
font-style: normal;
font-weight: 400;
src: url(/Tajawal-Regular.woff2) format('woff2');
font-display: block;
}
@font-face {
font-family: 'Baskerville';
font-style: normal;
font-weight: 400;
src: url(/Baskerville-Regular.woff2) format('woff2');
font-display: block;
}
:root {
--content-size: 1.25rem;
--content-line-height: 1.3;
--content-color: #1e1e1e;
--content-color-faded: #555;
--accent-color: #8c0606;
}
html {
font-family: 'Tajawal', sans-serif;
font-size: var(--content-size);
line-height: var(--content-line-height);
letter-spacing: -0.005em;
color: var(--content-color);
}
body {
margin: 0;
}
h1, h2, h3, h4, h5, h6 {
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Ubuntu, Arial, sans-serif;;
font-weight: 600;
color: #464646;
}
h1 {
font-variant: petite-caps;
margin-top: 0.75rem;
}
h1, h2 {
margin-bottom: 0.75rem;
}
h3 {
font-size: 1.2rem;
}
h4 {
font-size: 1.1rem;
}
h3, h4 {
margin-bottom: 0.5rem;
}
h5, h6 {
font-size: 1rem;
margin-bottom: 0;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
/*ul, ol {
margin: 0.5rem 0;
}*/
code {
background: #eee;
border-radius: 0.2rem;
font-family: Consolas, monospace;
font-size: 0.8rem;
padding: 0.05rem 0.2rem 0.1rem;
}
pre > code {
font-size: 0.8rem;
}
/* TESTING */

View File

@ -1,32 +1,24 @@
import { resolve } from 'node:path';
import staticAdapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { mdsvex } from 'mdsvex';
import staticAdapter from '@sveltejs/adapter-static';
import svp from 'svelte-preprocess';
import slug from './src/lib/slug.js';
import { localRemark } from './src/plugins/remark.js';
import { localRehype } from './src/plugins/rehype.js';
/** @type {import('@sveltejs/kit').Config} */
const config = {
extensions: ['.svelte', '.svx'],
preprocess: [
mdsvex({
layout: './src/lib/Post.svelte',
remarkPlugins: [localRemark],
rehypePlugins: [localRehype],
rehypePlugins: [slug],
}),
vitePreprocess(),
svp.scss(),
],
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
// hydrate the <div id="svelte"> element in src/app.html
adapter: staticAdapter(),
alias: {
'$styles': 'src/styles',
'$projects': 'src/projects',
}
prerender: {
default: true,
},
}
};

281
tmp/crane.svg Normal file
View File

@ -0,0 +1,281 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<!-- Crane image created by macrovector on Freepik: https://www.freepik.com/free-vector/construction-icons-set_1537228.htm#query=crane&position=3&from_view=keyword -->
<svg
width="28.305676mm"
height="28.174238mm"
viewBox="0 0 28.305676 28.174238"
version="1.1"
id="svg1392"
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
sodipodi:docname="crane.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1394"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="2.1089995"
inkscape:cx="-5.6899017"
inkscape:cy="78.710306"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1389" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-139.84716,-103.71933)">
<path
d="m 166.79605,124.82179 h 0.18627 v -20.83188 h -0.18627 v 20.83188"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path324" />
<path
d="m 166.25101,125.97184 h 1.27635 v -0.51893 c 0,-0.1323 -0.10724,-0.23919 -0.23918,-0.23919 h -0.79763 c -0.13229,0 -0.23954,0.10689 -0.23954,0.23919 v 0.51893"
style="fill:#e4731a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path326" />
<path
d="m 166.6934,125.21372 h 0.39193 v -0.64981 h -0.39193 v 0.64981"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path328" />
<path
d="m 165.62554,126.85626 c 0,0.69814 0.56585,1.26365 1.26365,1.26365 0.69779,0 1.26365,-0.56551 1.26365,-1.26365 0,-0.6978 -0.56586,-1.26365 -1.26365,-1.26365 -0.6978,0 -1.26365,0.56585 -1.26365,1.26365"
style="fill:#f9a727;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path330" />
<path
d="m 143.76637,124.8673 19.84057,-20.49675 -0.0935,-0.0903 -19.84093,20.49639 0.0938,0.0907"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path332" />
<path
d="m 149.82885,128.1506 2.90478,-6.56802 10.82675,-17.25718 1.80869,1.13488 -10.81899,17.2466 -4.61751,5.50863 z m 13.67261,-24.08167 -10.93188,17.42652 -2.97638,6.72711 0.37782,0.23707 4.72899,-5.64338 10.92553,-17.41488 -2.12408,-1.33244"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path334" />
<path
d="m 154.67073,122.68783 -0.0988,0.1577 -1.96638,-1.23367 0.0988,-0.15769 1.96638,1.23366"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path336" />
<path
d="m 154.712,122.74604 -0.18132,0.0413 -0.71791,-3.13443 0.18168,-0.0416 0.71755,3.13478"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path338" />
<path
d="m 155.85042,120.93135 -3.13443,0.71755 -0.0416,-0.18132 3.13478,-0.71791 0.0413,0.18168"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path340" />
<path
d="m 155.87935,120.76167 -0.0991,0.15769 -1.96639,-1.23366 0.0991,-0.1577 1.96639,1.23367"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path342" />
<path
d="m 155.92062,120.81952 -0.18168,0.0416 -0.71755,-3.13443 0.18133,-0.0416 0.7179,3.13443"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path344" />
<path
d="m 157.05903,119.00483 -3.13478,0.71791 -0.0413,-0.18133 3.13443,-0.7179 0.0416,0.18132"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path346" />
<path
d="m 157.08761,118.8355 -0.0988,0.15769 -1.96638,-1.23366 0.0988,-0.15769 1.96638,1.23366"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path348" />
<path
d="m 157.12888,118.89336 -0.18132,0.0416 -0.71791,-3.13478 0.18168,-0.0413 0.71755,3.13443"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path350" />
<path
d="m 158.2673,117.07867 -3.13443,0.7179 -0.0416,-0.18168 3.13478,-0.71755 0.0413,0.18133"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path352" />
<path
d="m 158.29623,116.90898 -0.0991,0.15769 -1.96639,-1.23331 0.0991,-0.15804 1.96639,1.23366"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path354" />
<path
d="m 158.3375,116.96719 -0.18168,0.0416 -0.71755,-3.13479 0.18133,-0.0413 0.7179,3.13443"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path356" />
<path
d="m 159.47592,115.1525 -3.13479,0.7179 -0.0413,-0.18168 3.13443,-0.71755 0.0416,0.18133"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path358" />
<path
d="m 159.50449,114.98282 -0.0988,0.15804 -1.96638,-1.23366 0.0988,-0.15805 1.96638,1.23367"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path360" />
<path
d="m 159.54577,115.04102 -0.18133,0.0416 -0.71791,-3.13478 0.18169,-0.0413 0.71755,3.13443"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path362" />
<path
d="m 160.68418,113.22633 -3.13443,0.71791 -0.0416,-0.18168 3.13478,-0.71755 0.0413,0.18132"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path364" />
<path
d="m 160.71311,113.05665 -0.0991,0.15769 -1.96639,-1.23331 0.0991,-0.15805 1.96639,1.23367"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path366" />
<path
d="m 160.75438,113.11486 -0.18168,0.0416 -0.7179,-3.13478 0.18168,-0.0413 0.7179,3.13443"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path368" />
<path
d="m 161.8928,111.30017 -3.13479,0.7179 -0.0413,-0.18168 3.13443,-0.71755 0.0416,0.18133"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path370" />
<path
d="m 161.92137,111.13048 -0.0991,0.15805 -1.96603,-1.23367 0.0988,-0.15804 1.96638,1.23366"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path372" />
<path
d="m 161.96265,111.18869 -0.18133,0.0416 -0.71791,-3.13479 0.18169,-0.0416 0.71755,3.13478"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path374" />
<path
d="m 163.10106,109.374 -3.13443,0.7179 -0.0416,-0.18168 3.13478,-0.7179 0.0413,0.18168"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path376" />
<path
d="m 163.12999,109.20432 -0.0991,0.15769 -1.96639,-1.23367 0.0991,-0.15769 1.96639,1.23367"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path378" />
<path
d="m 163.17126,109.26252 -0.18168,0.0413 -0.71755,-3.13443 0.18133,-0.0416 0.7179,3.13478"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path380" />
<path
d="m 164.30968,107.44783 -3.13479,0.71755 -0.0413,-0.18132 3.13443,-0.71791 0.0416,0.18168"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path382" />
<path
d="m 164.33825,107.27815 -0.0988,0.15769 -1.96638,-1.23366 0.0988,-0.1577 1.96638,1.23367"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path384" />
<path
d="m 164.37953,107.33636 -0.18133,0.0413 -0.7179,-3.13443 0.18168,-0.0416 0.71755,3.13479"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path386" />
<path
d="m 165.51794,105.52167 -3.13443,0.71755 -0.0416,-0.18133 3.13443,-0.7179 0.0416,0.18168"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path388" />
<path
d="m 153.38944,124.32366 -0.18133,0.0416 -0.64382,-2.81128 0.18133,-0.0416 0.64382,2.81128"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path390" />
<path
d="m 153.34817,124.26581 -0.0988,0.15769 -1.48908,-0.93415 0.0991,-0.1577 1.48873,0.93416"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path392" />
<path
d="m 154.64215,122.85752 -2.81163,0.64382 -0.0413,-0.18168 2.81129,-0.64382 0.0416,0.18168"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path394" />
<path
d="m 152.06476,125.91399 -0.18556,0.012 -0.16228,-2.50931 0.18591,-0.0123 0.16193,2.50966"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path396" />
<path
d="m 152.02137,125.84096 -0.0988,0.15769 -0.98954,-0.62053 0.0991,-0.15769 0.98919,0.62053"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path398" />
<path
d="m 153.33405,124.43056 -2.31598,0.95461 -0.0709,-0.17215 2.31599,-0.95462 0.0709,0.17216"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path400" />
<path
d="m 167.06769,103.88196 c -0.20602,-0.12912 -0.44944,-0.1838 -0.69074,-0.15523 l -2.87549,0.3422 -0.0935,0.16122 2.09691,1.31551 1.58573,-1.32151 c 0.0522,-0.0434 0.0801,-0.10936 0.0759,-0.17709 -0.005,-0.0677 -0.0416,-0.12912 -0.0988,-0.1651"
style="fill:#e4731a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path402" />
<path
d="m 163.77733,104.24179 2.71922,-0.33902 c 0.13687,-0.0169 0.27551,0.0138 0.39264,0.0871 l 0.0395,0.0247 c 0.0162,0.0102 0.0265,0.0275 0.0279,0.0466 0.001,0.019 -0.007,0.0378 -0.0215,0.0501 l -1.44533,1.20474 -1.71239,-1.07421"
style="fill:#f9a727;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path404" />
<path
d="m 145.63997,128.61168 h 3.81459 v 0.62547 h -3.81459 v -0.62547"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path406" />
<path
d="m 140.21425,126.26888 c 0.0783,-0.35489 0.34149,-0.63994 0.68897,-0.74683 l 6.68832,-2.05246 -0.3549,5.14209 h -6.77756 c -0.18556,0 -0.3609,-0.084 -0.47696,-0.2286 -0.11606,-0.14429 -0.16051,-0.33373 -0.12065,-0.5147 l 0.35278,-1.5995"
style="fill:#f9a727;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path408" />
<path
d="m 151.90037,131.89357 c 0.75917,0 1.37689,-0.61771 1.37689,-1.37724 0,-0.75918 -0.61772,-1.3769 -1.37689,-1.3769 -0.0243,-0.002 -3.12667,-0.22013 -4.15961,-0.22013 -1.03293,0 -4.13526,0.21802 -4.1663,0.22049 h -7.1e-4 c -0.75212,0 -1.36948,0.61736 -1.36948,1.37654 0,0.75953 0.61771,1.37724 1.37689,1.37724 h 8.31921"
style="fill:#252529;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path410" />
<path
d="m 143.58116,131.69778 c -0.65158,0 -1.18145,-0.52987 -1.18145,-1.18145 0,-0.65123 0.52669,-1.1811 1.17475,-1.1811 l 0.0141,-7.1e-4 c 0.03,-0.002 3.12667,-0.21943 4.15219,-0.21943 1.03082,0 4.11551,0.21696 4.14338,0.21908 l 0.008,7e-4 h 0.008 c 0.65158,0 1.18145,0.53023 1.18145,1.18146 0,0.65158 -0.52987,1.18145 -1.18145,1.18145 h -8.31921"
style="fill:#e4731a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path412" />
<path
d="m 150.71891,130.51633 c 0,-0.65229 0.52882,-1.18146 1.18146,-1.18146 0.65263,0 1.18145,0.52917 1.18145,1.18146 0,0.65263 -0.52882,1.18145 -1.18145,1.18145 -0.65264,0 -1.18146,-0.52882 -1.18146,-1.18145"
style="fill:#b8bbb6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path414" />
<path
d="m 151.90037,131.19507 c 0.37429,0 0.67874,-0.30445 0.67874,-0.67874 0,-0.3743 -0.30445,-0.67875 -0.67874,-0.67875 -0.3743,0 -0.67875,0.30445 -0.67875,0.67875 0,0.37429 0.30445,0.67874 0.67875,0.67874"
style="fill:#252529;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path416" />
<path
d="m 145.53555,129.58146 c 0,-0.19014 0.15416,-0.34431 0.34431,-0.34431 0.19015,0 0.34431,0.15417 0.34431,0.34431 0,0.19015 -0.15416,0.34432 -0.34431,0.34432 -0.19015,0 -0.34431,-0.15417 -0.34431,-0.34432"
style="fill:#252529;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path418" />
<path
d="m 145.54331,131.35311 c 0,-0.19014 0.15416,-0.34431 0.34431,-0.34431 0.19015,0 0.34396,0.15417 0.34396,0.34431 0,0.19015 -0.15381,0.34432 -0.34396,0.34432 -0.19015,0 -0.34431,-0.15417 -0.34431,-0.34432"
style="fill:#252529;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path420" />
<path
d="m 149.25735,131.35311 c 0,-0.19014 0.15417,-0.34431 0.34432,-0.34431 0.19014,0 0.34431,0.15417 0.34431,0.34431 0,0.19015 -0.15417,0.34432 -0.34431,0.34432 -0.19015,0 -0.34432,-0.15417 -0.34432,-0.34432"
style="fill:#252529;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path422" />
<path
d="m 149.94598,129.58146 c 0,-0.19014 -0.15417,-0.34431 -0.34431,-0.34431 -0.19015,0 -0.34432,0.15417 -0.34432,0.34431 0,0.19015 0.15417,0.34432 0.34432,0.34432 0.19014,0 0.34431,-0.15417 0.34431,-0.34432"
style="fill:#252529;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path424" />
<path
d="m 144.76261,130.51633 c 0,-0.65229 -0.52881,-1.18146 -1.18145,-1.18146 -0.65264,0 -1.18145,0.52917 -1.18145,1.18146 0,0.65263 0.52881,1.18145 1.18145,1.18145 0.65264,0 1.18145,-0.52882 1.18145,-1.18145"
style="fill:#b8bbb6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path426" />
<path
d="m 143.58116,131.19507 c 0.3743,0 0.67874,-0.30445 0.67874,-0.67874 0,-0.3743 -0.30444,-0.67875 -0.67874,-0.67875 -0.3743,0 -0.67874,0.30445 -0.67874,0.67875 0,0.37429 0.30444,0.67874 0.67874,0.67874"
style="fill:#252529;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path428" />
<path
d="m 143.43652,131.0088 h 8.60848 v -0.98495 h -8.60848 v 0.98495"
style="fill:#f9a727;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path430" />
<path
d="m 147.23664,128.61168 h 3.55812 c 0.33761,0 0.61172,-0.27376 0.61172,-0.61172 v -0.73236 c 0,-0.72073 0.11465,-2.63737 -0.76377,-3.69429 -0.0744,-0.0896 -0.18485,-0.14147 -0.30092,-0.14147 h -2.60173 c -0.16158,0 -0.29704,0.12277 -0.31256,0.28364 l -0.19086,1.97943 v 2.91677"
style="fill:#e4731a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path432" />
<path
d="m 150.76301,128.2649 c 0.16969,0 0.30797,-0.13794 0.30797,-0.30762 v -0.7165 c 0,-0.066 7.1e-4,-0.14217 0.002,-0.22648 0.01,-0.76165 0.03,-2.34562 -0.68192,-3.20216 -0.0173,-0.0212 -0.0437,-0.0335 -0.0709,-0.0335 h -2.54529 c -0.008,0 -0.0155,0.006 -0.0162,0.0148 l -0.16404,1.69827 c -0.0141,0.14464 0.025,0.28928 0.10971,0.40711 l 1.52153,2.11702 c 0.11219,0.15628 0.29316,0.24906 0.48578,0.24906 h 1.05163"
style="fill:#4c5462;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path434" />
<path
d="m 141.47331,125.91928 h 2.36891 c 0.0723,0 0.13052,0.0924 0.13052,0.20637 h -0.13052 -0.13053 -2.10785 -0.13053 -0.13053 c 0,-0.11394 0.0582,-0.20637 0.13053,-0.20637"
style="fill:#e4731a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path436" />
<path
d="m 141.47331,126.34649 h 2.36891 c 0.0723,0 0.13052,0.0924 0.13052,0.20673 h -0.13052 -0.13053 -2.10785 -0.13053 -0.13053 c 0,-0.1143 0.0582,-0.20673 0.13053,-0.20673"
style="fill:#e4731a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path438" />
<path
d="m 141.47331,126.77406 h 2.36891 c 0.0723,0 0.13052,0.0924 0.13052,0.20637 h -0.13052 -0.13053 -2.10785 -0.13053 -0.13053 c 0,-0.11394 0.0582,-0.20637 0.13053,-0.20637"
style="fill:#e4731a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352778"
id="path440" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

99
tmp/index.html Normal file
View File

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<title>Under Construction</title>
<style>
body {
margin: 0;
}
main {
background-color: #f2f2f2;
padding: 1rem;
height: 100vh;
width: 100vw;
justify-content: center;
align-content: center;
display: grid;
}
#hero {
padding: 4rem;
background-color: white;
border-radius: 100%;
}
#hero img {
width: 16rem;
}
p {
font-family: sans-serif;
margin-bottom: 2rem;
margin-top: 2rem;
font-size: 1.5rem;
text-align: center;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/luxon/3.0.4/luxon.min.js" integrity="sha512-XdACFfCJeqqfVU8mvvXReyFR130qjFvfv/PZOFGwVyBz0HC+57fNkSacMPF2Dyek5jqi4D7ykFrx/T7N6F2hwQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</head>
<body>
<main>
<p style="font-size:2.5rem;color:#505050">Coming Soon&trade;</p>
<div id="hero">
<img src="/crane.svg">
</div>
<p>
Under Construction for <br />
<span id="counter" style="margin-top:0.5rem"></span>
</p>
</main>
</body>
<script>
function u(v, unit) {
if (v === 1) {
return `${v} ${unit}`;
}
else {
return `${v} ${unit}s`;
}
}
function f(n) {
let s = n.toString();
if (s.length == 1) {
return '0' + s;
}
return s;
}
const start = luxon.DateTime.fromSeconds(1634529923);
function setDuration() {
var diff = luxon.DateTime.now().diff(start);
const years = Math.floor(diff.as('years'));
diff = diff.minus(luxon.Duration.fromObject({years}));
const months = Math.floor(diff.as('months'));
diff = diff.minus(luxon.Duration.fromObject({months}));
const days = Math.floor(diff.as('days'));
diff = diff.minus(luxon.Duration.fromObject({days}));
const hours = Math.floor(diff.as('hours'))
diff = diff.minus(luxon.Duration.fromObject({hours}));
const minutes = Math.floor(diff.as('minutes'));
diff = diff.minus(luxon.Duration.fromObject({minutes}));
const seconds = Math.floor(diff.as('seconds'));
diff = diff.minus(luxon.Duration.fromObject({seconds}));
const millis = diff.as('milliseconds');
const timeString = `${u(years, "year")}, ${u(months, "month")}, ${u(days, "day")}, ${f(hours)}:${f(minutes)}:${f(seconds)}.${Math.floor(millis / 100)}`;
document.getElementById('counter').innerHTML = timeString;
window.setTimeout(setDuration, 10);
}
setDuration();
</script>
</html>

View File

@ -1,6 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});