Compare commits
68 Commits
post-siden
...
13d0ac8de7
| Author | SHA1 | Date | |
|---|---|---|---|
| 13d0ac8de7 | |||
| b291e93e75 | |||
| b0e6576b33 | |||
| 6b0a985ee1 | |||
| dfdf6c6e66 | |||
| c28f340333 | |||
| 95b58b5615 | |||
| c81531a092 | |||
| bef34007d4 | |||
| 400da4e539 | |||
| 2b8989e02e | |||
| dfc09d8861 | |||
| 72a9a2e1f1 | |||
| 5b21f3b3a7 | |||
| 9b82175daa | |||
| 2f61b4a453 | |||
| 9c35c72989 | |||
| 723a5625b8 | |||
| 91a51d10f9 | |||
| 9eeb3e87bd | |||
| 716792e8a6 | |||
| 9399bcad96 | |||
| 8e58d6824a | |||
| 60bc85d49a | |||
| eb3992720b | |||
| 10d197e17d | |||
| b38f9f426a | |||
| 918791baf6 | |||
| 816f3a9c0f | |||
| ba4c2c2506 | |||
| 9a85bef2be | |||
| a6735c45f4 | |||
| b5ca20d739 | |||
| 9d3a59e554 | |||
| dd36f0e79e | |||
| 29e0b35ee4 | |||
| 60ce9f8e8d | |||
| 2fac675c0c | |||
| 8542cccd34 | |||
| 7df2de6c15 | |||
| 57476b9d80 | |||
| 924706b3b2 | |||
| 00300167eb | |||
| 859c34fd82 | |||
| 15ab47f4d8 | |||
| c231ed008d | |||
| 705e858170 | |||
| 5529bab8a3 | |||
| 5ec147f95f | |||
| 453dad1e0d | |||
| 7b3ae4dea8 | |||
| ce4ddf5a17 | |||
| c1e82ffb2c | |||
| 7fb1f05a1e | |||
| a28ee8b2f0 | |||
| 1b2d55173a | |||
| 3a59f45e58 | |||
| 0519291bda | |||
| d1aa23e7c7 | |||
| 25ce1b2d85 | |||
| 5817d94043 | |||
| 33d6838dc4 | |||
| b1dc3ae0ea | |||
| 6431267827 | |||
| 01fce255ac | |||
| 8272a4bd43 | |||
| b68220fa2e | |||
| 54bcec280d |
50
.astro/collections/blog.schema.json
Normal file
50
.astro/collections/blog.schema.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$ref": "#/definitions/blog",
|
||||||
|
"definitions": {
|
||||||
|
"blog": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "unix-time"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"draft": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"dropcap": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"toc": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"$schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
|
}
|
||||||
54
.astro/collections/posts.schema.json
Normal file
54
.astro/collections/posts.schema.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"$ref": "#/definitions/posts",
|
||||||
|
"definitions": {
|
||||||
|
"posts": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "unix-time"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"draft": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"dropcap": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"ascender",
|
||||||
|
"descender"
|
||||||
|
],
|
||||||
|
"default": "descender"
|
||||||
|
},
|
||||||
|
"toc": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"$schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
|
}
|
||||||
1
.astro/content-assets.mjs
Normal file
1
.astro/content-assets.mjs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default new Map();
|
||||||
6
.astro/content-modules.mjs
Normal file
6
.astro/content-modules.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export default new Map([
|
||||||
|
["posts/after.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=posts%2Fafter.mdx&astroContentModuleFlag=true")],
|
||||||
|
["posts/test.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=posts%2Ftest.mdx&astroContentModuleFlag=true")],
|
||||||
|
["posts/before.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=posts%2Fbefore.mdx&astroContentModuleFlag=true")]]);
|
||||||
|
|
||||||
218
.astro/content.d.ts
vendored
Normal file
218
.astro/content.d.ts
vendored
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
declare module 'astro:content' {
|
||||||
|
interface Render {
|
||||||
|
'.mdx': Promise<{
|
||||||
|
Content: import('astro').MDXContent;
|
||||||
|
headings: import('astro').MarkdownHeading[];
|
||||||
|
remarkPluginFrontmatter: Record<string, any>;
|
||||||
|
components: import('astro').MDXInstance<{}>['components'];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'astro:content' {
|
||||||
|
export interface RenderResult {
|
||||||
|
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
||||||
|
headings: import('astro').MarkdownHeading[];
|
||||||
|
remarkPluginFrontmatter: Record<string, any>;
|
||||||
|
}
|
||||||
|
interface Render {
|
||||||
|
'.md': Promise<RenderResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedContent {
|
||||||
|
html: string;
|
||||||
|
metadata?: {
|
||||||
|
imagePaths: Array<string>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'astro:content' {
|
||||||
|
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
||||||
|
|
||||||
|
export type CollectionKey = keyof AnyEntryMap;
|
||||||
|
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
|
||||||
|
|
||||||
|
export type ContentCollectionKey = keyof ContentEntryMap;
|
||||||
|
export type DataCollectionKey = keyof DataEntryMap;
|
||||||
|
|
||||||
|
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
||||||
|
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
|
||||||
|
ContentEntryMap[C]
|
||||||
|
>['slug'];
|
||||||
|
|
||||||
|
export type ReferenceDataEntry<
|
||||||
|
C extends CollectionKey,
|
||||||
|
E extends keyof DataEntryMap[C] = string,
|
||||||
|
> = {
|
||||||
|
collection: C;
|
||||||
|
id: E;
|
||||||
|
};
|
||||||
|
export type ReferenceContentEntry<
|
||||||
|
C extends keyof ContentEntryMap,
|
||||||
|
E extends ValidContentEntrySlug<C> | (string & {}) = string,
|
||||||
|
> = {
|
||||||
|
collection: C;
|
||||||
|
slug: E;
|
||||||
|
};
|
||||||
|
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
||||||
|
collection: C;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated Use `getEntry` instead. */
|
||||||
|
export function getEntryBySlug<
|
||||||
|
C extends keyof ContentEntryMap,
|
||||||
|
E extends ValidContentEntrySlug<C> | (string & {}),
|
||||||
|
>(
|
||||||
|
collection: C,
|
||||||
|
// Note that this has to accept a regular string too, for SSR
|
||||||
|
entrySlug: E,
|
||||||
|
): E extends ValidContentEntrySlug<C>
|
||||||
|
? Promise<CollectionEntry<C>>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
|
||||||
|
/** @deprecated Use `getEntry` instead. */
|
||||||
|
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
|
||||||
|
collection: C,
|
||||||
|
entryId: E,
|
||||||
|
): Promise<CollectionEntry<C>>;
|
||||||
|
|
||||||
|
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
|
||||||
|
collection: C,
|
||||||
|
filter?: (entry: CollectionEntry<C>) => entry is E,
|
||||||
|
): Promise<E[]>;
|
||||||
|
export function getCollection<C extends keyof AnyEntryMap>(
|
||||||
|
collection: C,
|
||||||
|
filter?: (entry: CollectionEntry<C>) => unknown,
|
||||||
|
): Promise<CollectionEntry<C>[]>;
|
||||||
|
|
||||||
|
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
||||||
|
collection: C,
|
||||||
|
filter?: LiveLoaderCollectionFilterType<C>,
|
||||||
|
): Promise<
|
||||||
|
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof ContentEntryMap,
|
||||||
|
E extends ValidContentEntrySlug<C> | (string & {}),
|
||||||
|
>(
|
||||||
|
entry: ReferenceContentEntry<C, E>,
|
||||||
|
): E extends ValidContentEntrySlug<C>
|
||||||
|
? Promise<CollectionEntry<C>>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof DataEntryMap,
|
||||||
|
E extends keyof DataEntryMap[C] | (string & {}),
|
||||||
|
>(
|
||||||
|
entry: ReferenceDataEntry<C, E>,
|
||||||
|
): E extends keyof DataEntryMap[C]
|
||||||
|
? Promise<DataEntryMap[C][E]>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof ContentEntryMap,
|
||||||
|
E extends ValidContentEntrySlug<C> | (string & {}),
|
||||||
|
>(
|
||||||
|
collection: C,
|
||||||
|
slug: E,
|
||||||
|
): E extends ValidContentEntrySlug<C>
|
||||||
|
? Promise<CollectionEntry<C>>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof DataEntryMap,
|
||||||
|
E extends keyof DataEntryMap[C] | (string & {}),
|
||||||
|
>(
|
||||||
|
collection: C,
|
||||||
|
id: E,
|
||||||
|
): E extends keyof DataEntryMap[C]
|
||||||
|
? string extends keyof DataEntryMap[C]
|
||||||
|
? Promise<DataEntryMap[C][E]> | undefined
|
||||||
|
: Promise<DataEntryMap[C][E]>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
||||||
|
collection: C,
|
||||||
|
filter: string | LiveLoaderEntryFilterType<C>,
|
||||||
|
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
||||||
|
|
||||||
|
/** Resolve an array of entry references from the same collection */
|
||||||
|
export function getEntries<C extends keyof ContentEntryMap>(
|
||||||
|
entries: ReferenceContentEntry<C, ValidContentEntrySlug<C>>[],
|
||||||
|
): Promise<CollectionEntry<C>[]>;
|
||||||
|
export function getEntries<C extends keyof DataEntryMap>(
|
||||||
|
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
||||||
|
): Promise<CollectionEntry<C>[]>;
|
||||||
|
|
||||||
|
export function render<C extends keyof AnyEntryMap>(
|
||||||
|
entry: AnyEntryMap[C][string],
|
||||||
|
): Promise<RenderResult>;
|
||||||
|
|
||||||
|
export function reference<C extends keyof AnyEntryMap>(
|
||||||
|
collection: C,
|
||||||
|
): import('astro/zod').ZodEffects<
|
||||||
|
import('astro/zod').ZodString,
|
||||||
|
C extends keyof ContentEntryMap
|
||||||
|
? ReferenceContentEntry<C, ValidContentEntrySlug<C>>
|
||||||
|
: ReferenceDataEntry<C, keyof DataEntryMap[C]>
|
||||||
|
>;
|
||||||
|
// Allow generic `string` to avoid excessive type errors in the config
|
||||||
|
// if `dev` is not running to update as you edit.
|
||||||
|
// Invalid collection names will be caught at build time.
|
||||||
|
export function reference<C extends string>(
|
||||||
|
collection: C,
|
||||||
|
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
|
||||||
|
|
||||||
|
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
||||||
|
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
|
||||||
|
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ContentEntryMap = {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataEntryMap = {
|
||||||
|
"posts": Record<string, {
|
||||||
|
id: string;
|
||||||
|
body?: string;
|
||||||
|
collection: "posts";
|
||||||
|
data: InferEntrySchema<"posts">;
|
||||||
|
rendered?: RenderedContent;
|
||||||
|
filePath?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnyEntryMap = ContentEntryMap & DataEntryMap;
|
||||||
|
|
||||||
|
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
||||||
|
infer TData,
|
||||||
|
infer TEntryFilter,
|
||||||
|
infer TCollectionFilter,
|
||||||
|
infer TError
|
||||||
|
>
|
||||||
|
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
||||||
|
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
||||||
|
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
|
||||||
|
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
||||||
|
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
||||||
|
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
||||||
|
|
||||||
|
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
||||||
|
LiveContentConfig['collections'][C]['schema'] extends undefined
|
||||||
|
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
||||||
|
: import('astro/zod').infer<
|
||||||
|
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
||||||
|
>;
|
||||||
|
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||||
|
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||||
|
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||||
|
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||||
|
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
||||||
|
LiveContentConfig['collections'][C]['loader']
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ContentConfig = typeof import("../src/content.config.js");
|
||||||
|
export type LiveContentConfig = never;
|
||||||
|
}
|
||||||
1
.astro/data-store.json
Normal file
1
.astro/data-store.json
Normal file
File diff suppressed because one or more lines are too long
5
.astro/settings.json
Normal file
5
.astro/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"_variables": {
|
||||||
|
"lastUpdateCheck": 1772188284763
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.astro/types.d.ts
vendored
Normal file
2
.astro/types.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
|
/// <reference path="content.d.ts" />
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1 @@
|
|||||||
.DS_Store
|
|
||||||
node_modules
|
node_modules
|
||||||
/build
|
|
||||||
/.svelte-kit
|
|
||||||
/package
|
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -1,38 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# create a new project in the current directory
|
|
||||||
npm init svelte@next
|
|
||||||
|
|
||||||
# create a new project in 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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
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 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
astro.config.mjs
Normal file
11
astro.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import mdx from '@astrojs/mdx';
|
||||||
|
import vue from '@astrojs/vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [
|
||||||
|
mdx(),
|
||||||
|
vue(),
|
||||||
|
],
|
||||||
|
prefetch: true,
|
||||||
|
});
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"$lib": ["src/lib"],
|
|
||||||
"$lib/*": ["src/lib/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
|
|
||||||
}
|
|
||||||
5736
package-lock.json
generated
5736
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -1,18 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "blog",
|
"dependencies": {
|
||||||
|
"@astrojs/check": "^0.9.6",
|
||||||
|
"@astrojs/mdx": "^4.3.13",
|
||||||
|
"@astrojs/rss": "^4.0.15",
|
||||||
|
"@astrojs/sitemap": "^3.7.0",
|
||||||
|
"@astrojs/vue": "^5.1.4",
|
||||||
|
"@fontsource-variable/baskervville": "^5.2.3",
|
||||||
|
"@fontsource-variable/baskervville-sc": "^5.2.3",
|
||||||
|
"@fontsource-variable/figtree": "^5.2.10",
|
||||||
|
"astro": "^5.18.0",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
|
},
|
||||||
|
"name": "astro",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "svelte-kit dev",
|
"dev": "astro dev",
|
||||||
"build": "svelte-kit build",
|
"build": "astro build",
|
||||||
"preview": "svelte-kit preview"
|
"preview": "astro preview",
|
||||||
},
|
"astro": "astro"
|
||||||
"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"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
8
posts/after.mdx
Normal file
8
posts/after.mdx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Example after post
|
||||||
|
date: 2026-02-28
|
||||||
|
---
|
||||||
|
|
||||||
|
## After
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet.
|
||||||
8
posts/before.mdx
Normal file
8
posts/before.mdx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Example previous post
|
||||||
|
date: 2026-02-21
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet.
|
||||||
48
posts/test.mdx
Normal file
48
posts/test.mdx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
title: This Is A Top-Level Heading
|
||||||
|
date: 2026-02-27
|
||||||
|
---
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut ac consectetur mi. Phasellus non risus vitae lorem scelerisque semper vel eget arcu. Nulla id malesuada velit. Pellentesque eu aliquam nisi. Cras lacinia enim sit amet ante tincidunt convallis. Donec leo nibh, posuere nec arcu in, congue tempus turpis. Maecenas accumsan mauris ut libero molestie, eget ultrices est faucibus. Donec sed ipsum eget erat pharetra tincidunt. Integer faucibus diam ut cursus cursus.
|
||||||
|
|
||||||
|
## A Second-level heading
|
||||||
|
|
||||||
|
Nulla at pulvinar quam. Interdum et malesuada fames ac ante ipsum primis in faucibus. In pretium laoreet egestas. Phasellus ut congue ligula, ut egestas sapien. Etiam congue dui at libero placerat, vel accumsan nunc ultrices. In ullamcorper ut nunc a elementum. Vivamus vehicula ut urna sed congue.
|
||||||
|
|
||||||
|
### Now let's try a third-level heading
|
||||||
|
|
||||||
|
Fusce varius lacinia ultrices. Cras ante velit, sagittis a commodo ac, faucibus nec tortor. Proin auctor, sapien nec elementum vestibulum, neque dolor consectetur lectus, a luctus ante nunc eget ex. Vestibulum bibendum lacus nec convallis bibendum. Nunc tincidunt elementum nulla, sit amet lacinia libero cursus non. Nam posuere ipsum sit amet elit accumsan, cursus euismod ligula scelerisque. Nam mattis sollicitudin semper. Morbi lacinia nec mi vel tempus. Cras auctor dui et turpis laoreet, ut vehicula magna dapibus. Fusce sit amet elit eget dolor varius tempus sed sit amet massa.
|
||||||
|
|
||||||
|
Fusce blandit scelerisque massa nec ultrices. Phasellus a cursus ex, sed aliquet justo. Proin sit amet lorem et urna viverra consectetur. In consectetur facilisis nulla, id accumsan metus lacinia quis. Sed nec magna pellentesque, ultricies nisi in, maximus sem. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam facilisis viverra metus, vitae auctor mi venenatis id. Fusce et risus non leo iaculis lacinia vitae ut metus. Phasellus id suscipit nisl, nec sodales velit. Morbi aliquet eros a est condimentum convallis.
|
||||||
|
|
||||||
|
#### Heading 4 coming up
|
||||||
|
|
||||||
|
Aenean facilisis eu velit vel semper. Sed imperdiet, lorem ut sagittis laoreet, turpis lorem venenatis justo, vel rhoncus enim lorem nec leo. Vestibulum sagittis orci nisl, vulputate tempor sem mattis eget. Pellentesque volutpat turpis sit amet est ultricies maximus. Aliquam sollicitudin semper enim, quis viverra mauris congue blandit. Vestibulum massa dui, efficitur quis lectus eu, bibendum vehicula dui. Suspendisse elementum, tellus a facilisis tincidunt, nulla leo viverra lacus, at lobortis massa ante non ex. Duis sed pretium nibh, eget molestie diam. Suspendisse congue augue metus, pellentesque faucibus magna auctor a. Praesent et sapien quis urna sollicitudin dapibus a ac justo. Integer lobortis, magna ac consectetur egestas, sapien dolor aliquet diam, ut tempor lectus metus sit amet libero. Fusce neque dui, mollis ac dui eget, iaculis semper lectus. Donec porttitor ante mauris, id condimentum neque ultrices in. Suspendisse commodo congue posuere.
|
||||||
|
|
||||||
|
##### Finally, we get to heading 5
|
||||||
|
|
||||||
|
Praesent non dignissim purus. Ut pharetra lectus sit amet tempor dapibus. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Cras dapibus libero vel enim consequat interdum. Aliquam ac est mollis, ornare dui sed, efficitur felis. Morbi aliquam neque neque, at facilisis arcu suscipit convallis. Integer pulvinar dui lectus, et luctus sapien porttitor nec. Vivamus placerat ultrices consectetur. Etiam molestie non nibh ut viverra. Suspendisse potenti. In sagittis leo non commodo ultricies. Donec vitae ultrices lacus, id mattis purus. Nulla ante lacus, auctor vitae enim sit amet, placerat placerat orci. In tempor eget nunc eget accumsan.
|
||||||
|
|
||||||
|
Fusce venenatis dolor at tincidunt commodo. Ut lacinia eu arcu eget pellentesque. Integer a nibh nisi. Phasellus semper quam at lacus finibus pellentesque. Sed porta varius imperdiet. Aenean tempor tellus odio, id sollicitudin neque pellentesque nec. Pellentesque vel ultrices felis. Vivamus eleifend quis leo nec tincidunt. Etiam magna quam, viverra non est et, vestibulum porta tellus. Nam nisi orci, pretium a quam id, malesuada sollicitudin mauris.
|
||||||
|
|
||||||
|
Nulla placerat, sem eget bibendum tincidunt, dui tortor ullamcorper ipsum, ut sollicitudin nibh tortor ac mi. Donec efficitur interdum ullamcorper. Cras ac molestie risus, non volutpat erat. Donec vel dignissim velit. Aliquam et turpis eget lorem cursus porttitor eu at ipsum. Nunc vitae leo non quam pharetra iaculis auctor et diam. Nullam convallis quam eu aliquet elementum. Etiam consectetur maximus tincidunt. Vivamus nulla risus, viverra nec mauris at, aliquet sagittis lorem. Duis tempor nunc sem, eget euismod urna porta sed.
|
||||||
|
|
||||||
|
## Another second-level heading to test TOC
|
||||||
|
|
||||||
|
Phasellus fermentum turpis vel porta vestibulum. Cras sed nisl at magna lacinia finibus tincidunt vitae massa. Maecenas lobortis, sapien non interdum placerat, arcu massa molestie lectus, eget ultricies dolor tortor nec nisl. Aliquam elementum facilisis nisi. Quisque lobortis tincidunt mauris, vel facilisis felis faucibus id. Sed pharetra ante ut quam fringilla fermentum. Nunc porta laoreet dui, ac vestibulum elit varius non. Quisque at risus cursus, mollis nisi vel, lacinia erat. In metus erat, iaculis vitae velit et, gravida hendrerit enim. Integer eget ipsum sit amet tellus ultrices congue. Curabitur ullamcorper vehicula eros, vel aliquam est egestas quis. Cras volutpat, nisi eu ultrices pretium, diam sapien dapibus orci, et tristique nulla lectus semper lectus.
|
||||||
|
|
||||||
|
Quisque ut pellentesque eros. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec blandit orci quis iaculis dignissim. Nulla facilisi. Suspendisse vehicula aliquet odio quis feugiat. Maecenas sed aliquam lectus. Nulla ut cursus dolor. Sed iaculis vulputate finibus. Proin non posuere nunc. Sed blandit nisi et euismod hendrerit. Nullam id urna dapibus, placerat mauris auctor, tristique leo. Phasellus mollis, sem vel viverra blandit, nisl nisi suscipit lorem, ut varius orci enim non sapien. Quisque at tempus ipsum. Nulla sit amet accumsan lacus.
|
||||||
|
|
||||||
|
### And another third-level heading
|
||||||
|
|
||||||
|
Mauris a porttitor justo. Sed eu maximus turpis, eu porta lectus. Morbi quis ullamcorper libero. Cras vehicula quis lorem non varius. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sit amet egestas odio. Quisque consectetur nunc enim, a molestie nibh congue id. Quisque efficitur accumsan luctus.
|
||||||
|
|
||||||
|
Duis viverra odio at dolor semper eleifend. Pellentesque tincidunt augue ultrices lobortis sodales. In semper felis lacus, vel fermentum dolor aliquet at. Aliquam tristique sagittis consequat. Maecenas sodales odio et mauris pulvinar varius. Phasellus imperdiet, magna id gravida efficitur, dui ipsum viverra odio, ut porttitor elit elit sed nisl. Vestibulum vestibulum eros et nisl tempus, ut dictum libero blandit. Sed tempor scelerisque elit non accumsan.
|
||||||
|
|
||||||
|
Fusce eget posuere diam, ut venenatis tellus. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nam quis maximus sem. Nam convallis euismod odio, feugiat volutpat risus sollicitudin eget. Vivamus et imperdiet tortor. Mauris fringilla metus eu eros convallis fringilla. Quisque sodales consequat auctor. Ut vitae ligula porttitor, consectetur mauris sit amet, scelerisque leo. Donec accumsan libero vel nulla placerat, a sagittis turpis pharetra. Fusce rutrum metus nunc. Etiam consequat lobortis blandit. Nunc sed odio ullamcorper, hendrerit dolor vel, euismod dui. Donec id bibendum est. Suspendisse massa ante, pretium ac orci et, tempus vehicula velit.
|
||||||
|
|
||||||
|
Aliquam feugiat interdum suscipit. Donec ac erat maximus, aliquam ligula sed, lacinia velit. Praesent a commodo ligula. Phasellus sed felis vel quam dictum facilisis. Nulla justo diam, tempus a efficitur ut, euismod non nisi. Pellentesque tristique rutrum erat, eu placerat tortor malesuada eu. Maecenas mollis mauris metus, sed pellentesque nisl posuere ut.
|
||||||
|
|
||||||
|
Donec malesuada sit amet diam rhoncus porttitor. Integer arcu elit, vestibulum ac condimentum a, scelerisque eget ligula. Aenean ultricies suscipit urna, at commodo tellus interdum nec. Sed feugiat nunc urna, non tincidunt libero elementum vel. Vestibulum non tincidunt ligula. Ut eu consectetur risus. Mauris vehicula ligula eget lacinia tempus.
|
||||||
|
|
||||||
|
Donec suscipit erat dui, eu porttitor augue condimentum vel. Sed et massa lacinia purus fermentum ultrices elementum sit amet tortor. Quisque feugiat, nulla non auctor egestas, sem odio mollis nisi, eget accumsan ante ipsum ac ex. Integer lobortis mi nunc, interdum blandit erat feugiat ac. Donec aliquam, felis in ultricies rhoncus, sem eros elementum lorem, non congue enim nunc quis ligula. Nulla facilisi. Vestibulum vitae justo ac justo porttitor rutrum a nec tellus. Duis congue lorem a semper maximus. Quisque consectetur dictum tellus, vel lobortis lorem sodales nec. Pellentesque sed enim felis. Aliquam non mattis sapien.
|
||||||
15
src/app.html
15
src/app.html
@@ -1,15 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="preload" href="/Tajawal-Regular.woff2" as="font" type="font/woff2" />
|
|
||||||
<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>
|
|
||||||
<div id="svelte">%svelte.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
124
src/components/Post.astro
Normal file
124
src/components/Post.astro
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
import '@fontsource-variable/baskervville-sc';
|
||||||
|
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
|
||||||
|
import { render } from 'astro:content';
|
||||||
|
import Toc from '@components/Toc.vue';
|
||||||
|
import { formatDate } from '@lib/datefmt';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
entry: CollectionEntry<'posts'>,
|
||||||
|
prevSlug: string | null,
|
||||||
|
nextSlug: string | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { entry, prevSlug, nextSlug } = Astro.props;
|
||||||
|
const { Content, headings } = await render(entry);
|
||||||
|
---
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 3-column grid: left gutter, center content, and right gutter */
|
||||||
|
article {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: 'Baskervville SC Variable';
|
||||||
|
}
|
||||||
|
|
||||||
|
#left-gutter {
|
||||||
|
grid-column: 1 / 2;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#right-gutter {
|
||||||
|
grid-column: 3 / 4;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
& a {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
& :global(section.post::first-letter) {
|
||||||
|
font-family: 'Baskervville';
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-dropcap-style="descender"] :global(section.post::first-letter) {
|
||||||
|
initial-letter: 2;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-dropcap-style="ascender"] :global(section.post::first-letter) {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<article class="prose" data-dropcap-style={entry.data.dropcap}>
|
||||||
|
<header class="title">
|
||||||
|
<h1>
|
||||||
|
<!-- <SmallCaps text={entry.data.title} upperWeight={500} lowerWeight={800} /> -->
|
||||||
|
{ entry.data.title }
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle">{ formatDate(entry.data.date) }</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="left-gutter">
|
||||||
|
<Toc client:load {headings} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="post">
|
||||||
|
<Content />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="right-gutter" />
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
{prevSlug && (
|
||||||
|
<a href={`/${prevSlug}`} data-astro-prefetch>Older</a>
|
||||||
|
)}
|
||||||
|
{nextSlug && (
|
||||||
|
<a href={`/${nextSlug}`} data-astro-prefetch>Newer</a>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
163
src/components/Toc.vue
Normal file
163
src/components/Toc.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MarkdownHeading } from 'astro';
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ headings: MarkdownHeading[] }>();
|
||||||
|
|
||||||
|
// headings deeper than h3 don't display well because they are too deeply indented
|
||||||
|
const headings = props.headings.filter(h => h.depth <= 3);
|
||||||
|
|
||||||
|
// for each heading slug, track whether the corresponding heading is above the cutoff point
|
||||||
|
// (the cutoff point being a hypothetical line 2/3 of the way up the viewport)
|
||||||
|
let headingStatuses = Object.fromEntries(headings.map(h => ([h.slug, false])));
|
||||||
|
// we need to store a reference to the observer so we can dispose of it on resize/unmount
|
||||||
|
let headingObserver: IntersectionObserver | null = null;
|
||||||
|
// the final slug that should be highlighted as "current" in the TOC
|
||||||
|
let currentSlug = ref('');
|
||||||
|
|
||||||
|
function handleIntersectionUpdate(entries: IntersectionObserverEntry[], headingElems: HTMLElement[]) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const slug = entry.target.id;
|
||||||
|
const status = entry.isIntersecting;
|
||||||
|
headingStatuses[slug] = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// headings are in DOM order, so this gives us the last heading that's still above the cutoff point
|
||||||
|
for (const elem of headingElems) {
|
||||||
|
if (headingStatuses[elem.id]) {
|
||||||
|
currentSlug.value = elem.id;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupObserver() {
|
||||||
|
// if there was already an observer, turn it off
|
||||||
|
if (headingObserver) {
|
||||||
|
headingObserver.disconnect();
|
||||||
|
}
|
||||||
|
const headingElems = headings.map(h => document.getElementById(h.slug)!);
|
||||||
|
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
entries => handleIntersectionUpdate(entries, headingElems),
|
||||||
|
// top margin equal to body height means that the intersection zone extends up beyond
|
||||||
|
// the top of the document, i.e. elements can only enter/leave the zone at the bottom
|
||||||
|
{ rootMargin: `${document.body.clientHeight}px 0px -66% 0px` },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const elem of headingElems) {
|
||||||
|
obs.observe(elem);
|
||||||
|
}
|
||||||
|
|
||||||
|
headingObserver = obs;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// create the observer once on component startup
|
||||||
|
setupObserver();
|
||||||
|
// any time the window resizes, the document height could change, so we need to recreate it
|
||||||
|
window.addEventListener('resize', setupObserver);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', setupObserver);
|
||||||
|
headingObserver?.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="toc">
|
||||||
|
<h5>C<span class="lower">ontents</span></h5>
|
||||||
|
<ul id="toc-list">
|
||||||
|
<li
|
||||||
|
v-for="heading in headings"
|
||||||
|
:data-current="heading.slug == currentSlug"
|
||||||
|
:style="`--depth: ${heading.depth}`"
|
||||||
|
>
|
||||||
|
<span v-show="heading.slug == currentSlug" class="marker"></span>
|
||||||
|
<a :href="`#${heading.slug}`">
|
||||||
|
{{ heading.text }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#toc {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.5rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
margin-right: 4rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
|
||||||
|
font-size: var(--content-size-sm);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-variant: petite-caps;
|
||||||
|
font-weight: 350;
|
||||||
|
font-size: var(--content-size);
|
||||||
|
font-family: 'Figtree Variable';
|
||||||
|
color: var(--content-color-faded);
|
||||||
|
max-width: fit-content;
|
||||||
|
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.35em;
|
||||||
|
|
||||||
|
/*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;
|
||||||
|
|
||||||
|
/* SmallCaps is an Astro component so we can't use it here, but we can fake it */
|
||||||
|
& .lower {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 0.45em;
|
||||||
|
margin-left: calc(0.75em * (var(--depth) - 2));
|
||||||
|
font-size: var(--content-size-sm);
|
||||||
|
/* make sure that one item wrapped across multiple lines doesn't just look like multiple items */
|
||||||
|
line-height: 1.15;
|
||||||
|
|
||||||
|
&[data-current="true"], &:hover {
|
||||||
|
color: var(--content-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker {
|
||||||
|
position: absolute;
|
||||||
|
left: -0.6rem;
|
||||||
|
top: 0.05em;
|
||||||
|
bottom: 0.2em;
|
||||||
|
width: 0.125rem;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
src/content.config.ts
Normal file
19
src/content.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineCollection } from 'astro:content';
|
||||||
|
|
||||||
|
import { glob } from 'astro/loaders';
|
||||||
|
|
||||||
|
import { z } from 'astro/zod';
|
||||||
|
|
||||||
|
|
||||||
|
const posts = defineCollection({
|
||||||
|
loader: glob({ pattern: '*.mdx', base: './posts' }),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
date: z.date(),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
dropcap: z.enum(['ascender', 'descender']).default('descender'),
|
||||||
|
toc: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { posts };
|
||||||
52
src/layouts/BaseLayout.astro
Normal file
52
src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
import '@styles/main.css';
|
||||||
|
---
|
||||||
|
|
||||||
|
<style>
|
||||||
|
header {
|
||||||
|
background-color: 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>
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a href="/" data-astro-prefetch>Home</a>
|
||||||
|
<a href="/posts" data-astro-prefetch>Posts</a>
|
||||||
|
<a href="/about" data-astro-prefetch>About</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<script>
|
|
||||||
// Usage: <Dropcap word="Lorem">ipsum dolor sit amet...</Dropcap>
|
|
||||||
|
|
||||||
export let word;
|
|
||||||
const initial = word.slice(0, 1);
|
|
||||||
const remainder = word.slice(1);
|
|
||||||
|
|
||||||
// a few letters are narrower at the top, so we need more of a shift
|
|
||||||
const shiftValues = {
|
|
||||||
A: '-0.45em',
|
|
||||||
L: '-0.3em',
|
|
||||||
R: '-0.25em',
|
|
||||||
};
|
|
||||||
|
|
||||||
const shift = shiftValues[initial] || '0em';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.drop-cap {
|
|
||||||
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 {
|
|
||||||
margin-left: var(--shift);
|
|
||||||
font-variant: petite-caps;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<span class="drop-cap">{initial}</span>
|
|
||||||
<span class="first-word" style:--shift={shift}>{remainder}</span>
|
|
||||||
<slot></slot>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<script context="module">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
|
|
||||||
function host(url) {
|
|
||||||
try {
|
|
||||||
let u = new URL(url);
|
|
||||||
return u.host;
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export let href; // we don't care about other attributes
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
{#if href.startsWith('/') || host(href) === $page.host}
|
|
||||||
<a sveltekit:prefetch {href}>
|
|
||||||
<slot></slot>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<a {href}>
|
|
||||||
<slot></slot>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script context="module">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { formatDate } from './datefmt.js';
|
|
||||||
import { makeSlug } from '$lib/slug.js';
|
|
||||||
|
|
||||||
import Link from './Link.svelte';
|
|
||||||
export { Link as a };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export let title, date;
|
|
||||||
export const description = '';
|
|
||||||
export const draft = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.subtitle {
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-style: italic;
|
|
||||||
margin-top: -0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{title}</title>
|
|
||||||
<link rel="stylesheet" href="/prism-dracula.css" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div id="post">
|
|
||||||
<h1 id="{makeSlug(title)}">{title}</h1>
|
|
||||||
<p class="subtitle">{formatDate(date)}</p>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
<style lang="scss">
|
|
||||||
/* always applicable */
|
|
||||||
:global(body) {
|
|
||||||
counter-reset: sidenote;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter {
|
|
||||||
counter-increment: sidenote;
|
|
||||||
color: #444;
|
|
||||||
margin-left: 0.05rem;
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
position: relative;
|
|
||||||
bottom: 0.3rem;
|
|
||||||
color: #8c0606;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenote {
|
|
||||||
color: #555;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: counter(sidenote) " ";
|
|
||||||
/* absolute positioning puts it at the top-left corner of the sidenote */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenote-toggle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* desktop display */
|
|
||||||
@media(min-width: 70em) {
|
|
||||||
.counter:after {
|
|
||||||
content: counter(sidenote);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenote {
|
|
||||||
--gap: 2rem;
|
|
||||||
--sidenote-width: min(14rem, calc(50vw - var(--gap) - var(--content-width) / 2));
|
|
||||||
max-width: var(--sidenote-width);
|
|
||||||
hyphens: auto;
|
|
||||||
position: relative;
|
|
||||||
float: right;
|
|
||||||
clear: right;
|
|
||||||
margin-right: calc(0rem - var(--sidenote-width) - var(--gap)); // gives us 2rem of space between content and sidenote
|
|
||||||
margin-bottom: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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">
|
|
||||||
var activeToggle = null;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const id = Math.random().toString().slice(2);
|
|
||||||
let toggle;
|
|
||||||
|
|
||||||
function toggleState() {
|
|
||||||
if (activeToggle === toggle) {
|
|
||||||
activeToggle = null;
|
|
||||||
}
|
|
||||||
else if (activeToggle !== null) {
|
|
||||||
activeToggle.checked = false;
|
|
||||||
activeToggle = toggle;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
activeToggle = toggle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<label for={id} on:click={toggleState} class="counter"></label>
|
|
||||||
<input {id} bind:this={toggle} type="checkbox" class="sidenote-toggle" />
|
|
||||||
<span class="sidenote">
|
|
||||||
<label class="dismiss" for={id} on:click={toggleState}>×</label>
|
|
||||||
<slot></slot>
|
|
||||||
</span>
|
|
||||||
@@ -17,15 +17,11 @@ const weekdays = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
export function formatDate(timestr) {
|
export function formatDate(date: Date) {
|
||||||
const datestr = timestr.slice(0, 10);
|
const year = date.getFullYear();
|
||||||
const [year, month, monthday] = datestr.split('-').map(n => parseInt(n));
|
const month = months[date.getMonth() - 1];
|
||||||
// for some reason the Date constructor expects the month index instead of ordinal
|
const monthday = ordinals[date.getDate() - 1];
|
||||||
const weekdayIdx = new Date(year, month - 1, monthday).getDay();
|
const weekday = weekdays[date.getDay() - 1];
|
||||||
const names = {
|
|
||||||
month: months[month - 1],
|
return `${weekday}, the ${monthday} of ${month}, A.D. ${year}`;
|
||||||
monthday: ordinals[monthday - 1],
|
|
||||||
weekday: weekdays[weekdayIdx],
|
|
||||||
}
|
|
||||||
return `${names.weekday}, the ${names.monthday} of ${names.month}, A.D. ${year}`;
|
|
||||||
}
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
src/pages/[slug].astro
Normal file
27
src/pages/[slug].astro
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import BaseLayout from '@layouts/BaseLayout.astro';
|
||||||
|
import Post from '@components/Post.astro';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const entries = await getCollection('posts');
|
||||||
|
entries.sort((a, b) => a.data.date.getTime() - b.data.date.getTime())
|
||||||
|
|
||||||
|
// for each route, the page gets passed the entry itself, plus the previous and next slugs
|
||||||
|
// (if any), so that it can render links to them
|
||||||
|
return entries.map((entry, idx) => {
|
||||||
|
const prevSlug = entries[idx - 1]?.id || null;
|
||||||
|
const nextSlug = entries[idx + 1]?.id || null;
|
||||||
|
return {
|
||||||
|
params: { slug: entry.id },
|
||||||
|
props: { entry, prevSlug, nextSlug },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout>
|
||||||
|
<Post {...Astro.props} />
|
||||||
|
</BaseLayout>
|
||||||
7
src/pages/index.astro
Normal file
7
src/pages/index.astro
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '@layouts/BaseLayout.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout>
|
||||||
|
<p>Index file</p>
|
||||||
|
</BaseLayout>
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<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} />
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
title: Exposing Docker Containers to your LAN
|
|
||||||
description: If, for some strange reason, you should want to do such a thing.
|
|
||||||
date: 2022-03-21
|
|
||||||
---
|
|
||||||
<script>
|
|
||||||
import Sidenote from '$lib/Sidenote.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
A while back I had occasion to make a number of docker containers directly accessible on the LAN, i.e. without all the usual ceremony of port-forwardism that Docker requires. In retrospect I made it a lot more complicated than it had to be, but I wanted to document the process anyway because you never know when that sort of thing might come in handy.
|
|
||||||
|
|
||||||
## Aside: You Probably Don't Want This
|
|
||||||
|
|
||||||
In my case, the reason for doing this was so that I could expose multiple difference services that all wanted to bind the same port. In other words, given that I was going to be hosting more than one HTTP-based application, I didn't want to have to remember (and type out all the time) a bunch of different ports to distinguish between the services I wanted to talk to. DNS is great, but it only points to IP addresses<Sidenote>Well, SRV records can include ports, but browsers don't pay attention to those.</Sidenote>, after all.
|
|
||||||
|
|
||||||
That said, had I only realized it at the time, there's a much better way to accomplish this than exposing entire containers to the LAN, and much less... questionable from a security standpoint: **Just bind multiple IPs on the host**. Docker allows you to specify what IP address to bind when forwarding a port to a container, so you can forward e.g. 192.168.50.21:80 to App 1, and 192.168.50.22:80 to App 2, and neither the apps nor the users need ever worry their pretty little heads about a thing. This is better than exposing the container directly - containerized applications generally expect to be pretty isolated from a networking point of view, with external traffic only hitting the one or two ports that they specify as their window to the outside world. So if some packaged application has to run its own Redis server<Sidenote>Because some people just can't help jamming Redis into every app they write, it's like a spinal reflex or something.</Sidenote>, it might not take the extra step of only binding to localhost, and congratulations now anyone on the LAN can read your session cookies or whatever.<Sidenote>Alternatively you can do what I did: Set up a _shared_ Redis server for a _bunch_ of different applications, in Docker of course, and then _knowingly_ expose that to the entire LAN, and damn the torpedoes. I cannot legally recommend this course of action.</Sidenote>
|
|
||||||
|
|
||||||
The caveat here is of course that you need to be sure the IP addresses you use aren't going to be stolen out from under you by somebody's iPad or something next time it connects to the network. This is easy if you control the DHCP server, and either easy or impossible if you don't. For reasons that I've never fully understood, but _probably_ boil down to leaving room for people to do exactly this sort of thing, many standard DHCP configurations assign IPs from just a portion of the available range. `.100` is a common start point in a /24 network, so you can usually expect that `.2`-`.99`<Sidenote>Someday I'm going to set up a network where the router is at, like, .233 or something instead of .1, just to freak out the one or two people who might ever notice.</Sidenote> will be available for you to work your will upon.
|
|
||||||
|
|
||||||
The worse solution (exposing containers directly to the LAN) has this same caveat, so it's just worse in every way, there's really no advantage except that _maybe_ it's lower-overhead, since not as much forwarding of packets needs to take place. So yeah, probably just don't unless your containerized application _really needs_ Layer 2 access to the network, like it's an intrusion detection system and needs keep an eye on broadcast traffic or something.
|
|
||||||
|
|
||||||
## Anyway
|
|
||||||
|
|
||||||
With that all out of the way, having hopefully convinced you that this is almost never a good idea, here's how to do it:
|
|
||||||
|
|
||||||
```
|
|
||||||
docker network create \\
|
|
||||||
-d ipvlan \\
|
|
||||||
--subnet 192.168.50.0/24 \\
|
|
||||||
--gateway 192.168.50.1 \\
|
|
||||||
-o parent=eth0 \\
|
|
||||||
lan
|
|
||||||
|
|
||||||
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.)
|
|
||||||
|
|
||||||
This isn't actually what the first draft of this post said. Initially I was going to suggest using the `macvlan` driver, and then go into a whole spiel about how if you do this and you also want the host to be able to talk to its containers, then you have to create _another_ (non-Docker-managed) `macvlan` interface in `bridge` mode, then route an IP range or two via that interface, as described [here](https://blog.oddbit.com/post/2018-03-12-using-docker-macvlan-networks/).
|
|
||||||
|
|
||||||
`ipvlan` is a lot easier, though, and gives you almost exactly the same result. The only difference is that with `macvlan` Docker will actually make up a MAC address for the virtual interface and respond to ARP queries and so on with that. With `ipvlan` it just uses the host MAC. My suspicion is that this is probably another argument _for_ `ipvlan`, as I think I remember reading that multiple MAC addresses on one physical interface is considered a Bad Sign by some network watchdog types of things.
|
|
||||||
|
|
||||||
I'm really not sure why I ended up going for `macvlan` in my own case. Maybe `ipvlan` was a later invention so the guides I came across weren't aware of it? Anyway it's there, and it works a lot better than `macvlan` for most use cases, so it's almost certainly what you should use.<Sidenote>In the event that you need to use either of them, that is. Which you probably [don't](#aside-you-probably-dont-want-this).</Sidenote>
|
|
||||||
|
|
||||||
So there you have it. You can dump containers on your LAN, and they will (from a networking standpoint) behave as if they were their own machines. But you probably don't want to.
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
---
|
|
||||||
title: The Hitchiker's Guide to Mesh VPNs
|
|
||||||
description: The golden age of VPNery is upon us.
|
|
||||||
date: 2022-03-17
|
|
||||||
---
|
|
||||||
<script>
|
|
||||||
import Sidenote from '$lib/Sidenote.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
Recently at work we've been moving to a new VPN, and naturally as part of that process we done a bunch of research into the available options before settling on one. Mostly I want to document that for my own future reference, so that if this question comes up again I don't have to go redo it all, but if it ends up being helpful to someone else someday then that's great too. (If I ever get this blog site launched, that is. Currently it's not looking too good.)
|
|
||||||
|
|
||||||
TL;DR: We ended up going with [Tailscale](https://tailscale.com), because it looked the most user-friendly, had the security features we wanted, and was something I had already used personally so it was more of a known quantity than some of the others.
|
|
||||||
|
|
||||||
## A Brief History of VPNing
|
|
||||||
|
|
||||||
There are a lot of different VPN softwares out there. Traditionally there were two main types: site-to-site and client-server. Site-to-site VPNs were for connecting geographically separated LANs into one big super-LAN, useful if you had one company with two offices in different cities or something. Client-server VPNs were for hooking individual users outside the office into your corporate network so that they could access the fileshare, locally-hosted whatevers, and so on. Maybe you could even enforce traffic filtering policies by forcing all of their traffic to go through the VPN first, where it could be inspected and potentially blocked if it were determined to be non-kosher. Seems a bit control-freaky to me, but maybe if I were responsible for the network administration of thousands of users I'd feel differently.
|
|
||||||
|
|
||||||
More recently, things have started to change in the VPN world. A new power is rising; its victory is at hand. This night, the land will be stained with the blood of IPSec. Erm. Ahem. The new breed is "mesh VPNs," and they're really starting to take hold.<Sidenote>To be fair, they're not _new_ exactly; the oldest one of which I am aware has been arround since 1998. It's just that for some reason nobody paid them much attention until more recently.</Sidenote> The main difference is that instead of being site-to-site or client-server (also known as hub-and-spoke), mesh VPNs establish a _direct_ network transit between any pair of devices that want to communicate. Which is great; it means you can send a packet straight to _any other machine on your network_. You can extend your LAN across any geographical boundaries and (almost) any network conditions, while still remaining secure in the knowledge that your communication is totally encrypted and eavesdropper-proof.
|
|
||||||
|
|
||||||
Actually, it's even _better_ than a LAN, because you can enforce access control rules on packets flowing between any two nodes, rather than just packets that cross a network boundary. This is a Big Deal, because it means that your virtual "LAN" is no longer the soft underbelly of your network security. In the olden days, someone who managed to get a foothold in your network was pretty much at liberty to talk to anyone and anything, because what was your firewall going to do about it? ARP-spoof every client on the network so it can inspect the traffic? Sounds like a fast track to a flaky and congested network to me. With a mesh VPN, on the other hand, since every packet between hosts is passing "through" the VPN, it's free to enforce whatever access controls your heart desires.<Sidenote>You may point out that the only way to do this is to leave it up to the individual nodes to enforce these ACLs, and you'd be right. But that's not really a problem, either. Yes, two nodes could collaborate to twiddle with their local copy of the ACL and pass traffic that you haven't permitted. But you know what else two collaborators could do? Send each other emails. Or chat on Discord. Or mail USB sticks across the country. Your firewall isn't there to prevent communication between two consenting parties, it's there to prevent communication between one consenting and one _unconsenting_ party.</Sidenote>
|
|
||||||
|
|
||||||
If you've worked with cloud services much you'll notice this is more or less exactly what "security groups" do, and that's no accident. The big public clouds have been using software-defined networking since before everybody else, because you kind of have to when you sell virtual servers. You're already halfway there because if the servers are virtual, then so are their network interfaces, right? And you don't want to just dump them onto a physical LAN because that's just asking for any Tom, Dick or Harry with a credit card to come along and sniff your network traffic. So it's security groups and "virtual private clouds" all the way.
|
|
||||||
|
|
||||||
## Table Stakes
|
|
||||||
|
|
||||||
All of which is to say, in a somewhat meandering way, that we decided pretty early on that we wanted a mesh VPN solution to replace our existing hub-and-spoke architecture. For us the security implications (as discussed above) were the main draw, but a mesh VPN has other advantages over the more classical type. For one thing, it's a lot easier to scale your VPN up when all the network has to do is route packets, and individual hosts are responsible for the encryption/decryption part. Also, mesh VPNs can have better latency because they're a lot more flexible with routing - you're able to take full advantage of the internet's existing mechanisms for minimizing transit time, instead of having to make detours through a small set of required nodes. Also, NAT holepunching. _Technically_ not required for a mesh VPN, but pretty useless without it, since the majority of internet-connected devices in the world tend to be behind NATs.<Sidenote>I haven't checked this. Don't quote me on it.</Sidenote>
|
|
||||||
|
|
||||||
So for us, the boxes that a VPN needed to tick were:
|
|
||||||
|
|
||||||
* Mesh topology
|
|
||||||
* NAT holepunching
|
|
||||||
* With ACLs
|
|
||||||
* User-friendly enough that we could feasibly expect people to install it on their own machines
|
|
||||||
|
|
||||||
## Interlude: Wireguard
|
|
||||||
|
|
||||||
If you've been following the state of the art in VPNery for the last few years, then you've heard of [Wireguard](https://wireguard.com). It first started making serious waves (to my knowledge) in 2018, when Linus Torvalds referred to it as a "work of art" (as compared to OpenVPN and IPSec) on the Linux kernel mailing list. Given Torvalds' reputation for acerbic comments regarding code quality, the fact that he was referring to _someone else's code_ as a "work of art" raised a few eyebrows. One thing led to another, eventually Wireguard was adopted into the mainline Linux kernel, and Jason A. Donenfeld became the herald of the new Golden Age of Networking.
|
|
||||||
|
|
||||||
Wireguard is relevant to our discussion for being an encrypted tunnel protocol that Works Really Well, which is why at least three of the options I've looked at are based on it. I say "based on", however, because Wireguard is _not_ a mesh VPN on its own. By itself, Wireguard gives you nothing more than an encrypted tunnel between two points. It's fast and low-latency and (can be) in-kernel so it's very low-overhead, and the connections are all secured with public/private keypairs like SSH. Also like SSH, however, it gives you exactly zero help when it comes to distributing those keys, and if you're looking for some form of automatic peer discovery you're barking up the wrong tree.
|
|
||||||
|
|
||||||
## The Field
|
|
||||||
|
|
||||||
That's ok, though, because there are a lot mesh VPNs out there that do all those things, some of them built on Wireguard and some not, so let's talk about them!
|
|
||||||
|
|
||||||
### ZeroTier
|
|
||||||
|
|
||||||
I'm starting with this one because it's one of the most well-established players (been around since 2011, in fact) and was the one I personally discovered first. ZeroTier is a mesh VPN that provides ACLs and NAT holepunching, like everything that we're interested in. Unlike _any_ of the others, though, it actually emulates at layer *2* rather than layer 3, meaning that it can have _broadcast traffic_. This immediately makes it interesting from a user-friendliness standpoint, since how great would it be if your fileshare automatically showed up on your VPN via its built-in mDNS (or whatever) advertisement features?
|
|
||||||
|
|
||||||
Another nice feature of Zerotier is that connecting to a network requires a lot less ceremony than some of the other options. Just enter the 16-digit network id, then wait for the network admin to approve your join request. Or, if it's a public<Sidenote>Yes, a "public virtual private network". No, it doesn't have to make sense.</Sidenote> ZeroTier network, you get in immediately.
|
|
||||||
|
|
||||||
That's the theory, at least. In practice - well, in practice I haven't tried it with broadcast traffic. I have, however, tried it to connect my own personal network of devices (desktops, laptop, Raspberry Pi, a server or two, and some cloud VMs). Short story: It didn't work all that well for me. To be fair, I could usually get _some_ kind of connectivity, but it was very unpredictable in both bandwidth and latency. In a particularly frustrating twist, the two nodes that I had the _most_ trouble connecting were cloud VMs from different providers, which makes _no sense_ because the main thing that kills these sorts of mesh VPNs is NAT, and the VMs all had _public IPv4 addresses._ This should have been _easy!_
|
|
||||||
|
|
||||||
Anyway, although I no longer use it, I do retain a soft spot in my heart for Zerotier, and it has some characteristics (the aforementioned VLAN properties) that really set it apart from the rest. If I were trying to set up a virtual LAN party with a group of friends to play a local-network-only game, I'd probably try Zerotier first.
|
|
||||||
|
|
||||||
Also you can self-host the network controller, although I think you lose the shiny web interface if you do that and have to use the API to configure it.
|
|
||||||
|
|
||||||
### Nebula
|
|
||||||
|
|
||||||
[Nebula](https://github.com/slackhq/nebula) is one of the newer crop of mesh VPNs that seem to be popping up like weeds lately. It ticks most of our boxes (mesh, ACLs, NAT holepunching) but does so in ways that all seem just _ever_ so slightly sub-optimal (for us, at least). It's based on the Noise protocol framework<Sidenote>Which I understand at only the most basic level. Something something ChaCha Poly1305 elliptic curves?</Sidenote>, on which Wireguard is also based, making them... sibling protocols, I guess?
|
|
||||||
|
|
||||||
Nebula was developed by Slack to support their... somewhat _interesting_ [architecture](https://slack.engineering/building-the-next-evolution-of-cloud-networks-at-slack/),<Sidenote>Look, I don't work at Slack, I'm not terribly familiar with their requirements... but is it really the simplest solution to use _hundreds of AWS accounts_ to manage your resources? At that scale, can't you just... rent a bunch of bare metal servers and hook them into a big cluster with, like, Nomad and Consul or something? I dunno. Maybe it's all justified, I'm just not convinced.</Sidenote> and seems like a pretty solid piece of work. It's completely self-hostable, which I consider a plus, it uses modern cryptography, and it probably works very well for the use case for which it was designed. Unfortunately for our use case, it's not really designed to be used directly by end-users, e.g. the only way to configure it seems to be through its main config file, and the only way to operate it is through the CLI. Not a problem when all you need to do is hook together a bunch of cloud VMs and the odd dev machine or two, but not great if you want Janice over in HR to be able to talk to the network share.
|
|
||||||
|
|
||||||
The other thing I'm not a huge fan of is that as far as I can tell, firewall rules are configured individually on each host. Again, not a problem when you're spinning up VMs from some kind of master image that has the rules all baked in, but not something I want to repeat 50 times on everybody's laptop (or worse, walk them through writing YAML over screen-sharing or something.) I'm sure it wouldn't be too hard to build some kind of automation to work around that, but if we were looking to build our own thing we would have just started with vanilla Wireguard and built up from there.
|
|
||||||
|
|
||||||
### Innernet
|
|
||||||
|
|
||||||
Which leads us to [Innernet](https://blog.tonari.no/introducing-innernet), which is pretty much just exactly that. The introductory blog post says it better than I can:
|
|
||||||
|
|
||||||
> _*In the beginning*_, we had a shared manually-edited WireGuard config file and many sighs were heard whenever we needed to add a new peer to the network.
|
|
||||||
> _*In the middle ages*_, there were bash scripts and a weird Vault backend with questionable-at-best maintainability that got new machines on the network and coordinated things like IP allocation. Many groans could be heard whenever these flimsy scripts broke for any reason.
|
|
||||||
> _*In the end*_, we decided to sit down, sigh one long and hopefully final time, and write `innernet`.
|
|
||||||
|
|
||||||
So, great! What's more, it's self-hosted, built in Rust (with ♥, no doubt) and uses kernel-mode Wireguard (actually I think it uses "whatever Wireguard is available on the host system", which is kernel-mode if you're on Linux and not otherwise). Unfortuantely, it's still a fairly immature project, so it's lacking things like (again) user-friendliness, which may or may not be a dealbreaker depending on your wants and needs.
|
|
||||||
|
|
||||||
Even more unfortunately, it bases its security model around CIDR network segments, just like old-skool corporate networks, which to my mind is a huge step backwards from the more flexible "security group" model that the other candidates use. The critical difference is that a given device has only _one_ "targetable attribute" with which to specify it in your firewal rules. This tends to lead to over-proliferation of access because Device A is in Group Z but needs access to Thing Q, which the rest of Group Z doesn't _really_ need but you also don't want to move Device A into its own special group because now you have to duplicate the access rules for Group Z, and then if they change you have to remember to update the new group too, and who wants to deal with that? So you give all of Group Z access to Thing Q, and before you know it you're back to having a "soft underbelly" of a LAN where an attacker who gets in can talk to virtually anything they want to if they jump through a few hoops.
|
|
||||||
|
|
||||||
The Innernet documentation points out that CIDRs can be nested, which is true, so I guess you can have an `engineering` CIDR and then within that an `engineering-managers` CIDR that has all the access of `engineering` plus a few. But what happens when you have a `sales` CIDR with a `sales-manager` who needs the managery bits to match `engineering-managers`, but not the engineering bits, and oh no you're back to duplicating firewall rules because you've locked yourself into an arbitrary limit of one "role" per device?
|
|
||||||
|
|
||||||
In theory you could solve this by allowing a single device to have multiple IPs in multiple different CIDRs, but it's apparently a core principle of Innernet's design that "Peers always have only one assigned IP address, and that address is permanently associated with them." So that's out.
|
|
||||||
|
|
||||||
(I'm also less than entirely comfortable with fixed-size address spaces in an environment where they're not _really_ necessary, because what happens when the /24 you've allocated for `doodad-watchers` needs its 257th member? But that's an ancillary concern and could probably be managed fairly easily by careful allocation of address blocks.)
|
|
||||||
|
|
||||||
In conclusion, I'm conflicted. There's a lot to like about Innernet, and I'm interested to see where they take it as time goes on, but I find myself disagreeing just a little too much with some of the fundamental design choices. I may still end up trying it out some day, since setting up a new VPN for my personal fleet of network-connected thingies is my idea of a fun weekend, but I doubt I'll ever use it seriously unless there's some signficant change in how access control works.
|
|
||||||
|
|
||||||
Oh yeah, and there's no Windows client as yet. Hard to sell switching your whole workforce to Linux just so you can use a cool VPN thingy.
|
|
||||||
|
|
||||||
### Cloudflare One
|
|
||||||
|
|
||||||
Ok, I'm cheating a little bit. [Cloudflare One](https://www.cloudflare.com/cloudflare-one/) technically isn't a mesh VPN, because it always routes your traffic through a Cloudflare gateway, rather than establishing direct links between devices and letting them do the communicating. I'm including it here anyway, because the _result_ is pretty comparable to what you get from these mesh VPNs: A logically "flat" network in which any node can communicate with any other node, subject to centrally-administered access control rules. It even gets you _most_ of the latency and throughput advantages you'd get from a true mesh VPN, because Cloudflare's edge is basically everywhere and its capacity is effectively infinite, as far as the lowly user is concerned.
|
|
||||||
|
|
||||||
It's surprisingly inexpensive, as well, with a free tier for up to 50 users, a $7/user/month tier for intermediate cases, and a "call us for pricing" option if you tend to use scientific notation when you talk about your company's market cap. We ended up deciding against it anyway, largely because of some anecdotal claims about its user-friendliness being not-so-great, and the fact that... well, Cloudflare already gets their greasy paws<Sidenote>He said, on the blog site hosted behind Cloudflare's CDN.</Sidenote> on something like 15% of internet traffic as it stands, and do we really want to contribute to that?<Sidenote>Not that I have anything against Cloudflare, mind. They seem great so far. They just give me the same feeling as 2010-era Google, and look how that turned out.</Sidenote>
|
|
||||||
|
|
||||||
Also, the one place where you'd feel the lack of true mesh-ness would be LAN communication, which was actually a concern for us. Proper mesh VPNs can detect when two clients are on the same LAN and route their traffic accordingly, so lower latency, higher throughput, yadda yadda. As far as I can tell, Cloudflare's needs every packet to pass through the Cloudflare edge (aka "the internet"), meaning it turns LAN hops into WAN hops. Probably not a big deal for their customers, since this product is pretty clearly targeting Proper Enterprise types, and they undoubtedly have built-up layers of LAN cruft that you couldn't dig your way out of with a backhoe and so wouldn't be using it within their LAN anyway. A slightly bigger deal for us, since "route even LAN traffic through the VPN so we can enforce ACLs" was one of our stated goals.
|
|
||||||
|
|
||||||
### Netmaker
|
|
||||||
|
|
||||||
Netmaker is a newcomer to this space; the first commit in their Github repo is from March of 2021. It looks to be quite functional, though, with the whole nine yards - full mesh, NAT holepunching, ACLs, and traffic relays for those stubborn NATs that just can't be punched. Pretty impessive for a year and change, which is probably why they got funded by YCombinator.
|
|
||||||
|
|
||||||
It's fully self-hostable, with some fancy options for HA cluster-type setups if you want to do that. (The Netmaker docs also introduced me to [rqlite](https://github.com/rqlite/rqlite), which looks like quite an interesting project.) We probably came closer to settling on this one than any others in this list (other than the one we did settle on), and I'd still really like to play with it at some point.
|
|
||||||
|
|
||||||
It seems to use kernel-mode Wireguard, which is a big plus in my book. Presumably that's platform-dependent, e.g. I don't think MacOS and maybe Windows have kernel-mode Wireguard yet, but presumably it will be easy to slot in once it does arrive on a given platform.
|
|
||||||
|
|
||||||
My one gripe is with the way it does ACLs. It looks like the ACL configuration is just a simple yes/no to every distinct pair of peers in your network, the question being "can these two peers communicate dircectly?" No mention of ports, either source<Sidenote>To be fair, the concept of the "source port" is largely irrelevant when dealing with software-defined networking. In my experience you tend think about _flows_ more than individual packets (ZeroTier being the exception), so the source port is just whatever ephemeral port gets assigned to the connection.</Sidenote> or destination. Also no mention of groups/roles/tags/etc, which means that the number of buttons to click is going to scale with the square of your network size. Not my idea of fun. On the other hand, ACLs are a very new feature (just added in the last release), so maybe they will improve over time.
|
|
||||||
|
|
||||||
Regardless, Netmaker looks like an extremely interesting project and I'd very much like to try it out at some point.
|
|
||||||
|
|
||||||
### [Tailscale](https://tailscale.com/)
|
|
||||||
|
|
||||||
Obviously, this is the one we settled on. The Cadillac of the bunch. Although not the oldest, I'd probably call Tailscale the most well-established of the candidates in this list. It didn't take them very long (I think they started in 2018 or 2019?) because their product is just really damn good. It slices, it dices, it meshes, it firewalls, and it even twiddles with your DNS settings so that you can type `ping homepi` and `homepi` will resolve to the Tailscale-internal IP of the raspberry pi that's hanging out with the dust bunnies next to your cable modem.
|
|
||||||
|
|
||||||
So why did we like it? Well, for one I had been using it for about a year and a half to connect my personal devices, so I knew it would get the job done. That's not the only reason, though. A few of the others:
|
|
||||||
|
|
||||||
**User-friendliness:** Installing Tailscale is basically just downloading the app and logging in. There's pratcically nothing to it. After that it just hums along quietly in the background, and your things are magically connected to your other things whenver you want them to be. This is what networking should feel like. Too bad script kiddies with DDoS botnets have ruined it all for us over the last 20 years.
|
|
||||||
|
|
||||||
**The Best NAT holepunching:** I don't think I'm exaggerating here. As [they explain](https://tailscale.com/blog/how-nat-traversal-works/), Tailscale goes a lot further than "try sending packets both ways and give up if it doesn't work." Among the various tricks it pulls is sending a whole bunch of packets and hoping the birthday paradox kicks in and one of them gets through, which I think is pretty clever.
|
|
||||||
|
|
||||||
**Magic DNS:** To be fair, I haven't looked super deeply into what all of the competitors do for this, but it's a pretty big quality-of-life feature. Admittedly Tailscale IPs are stable (as long as you don't clear the device's local state), so you could just stick a public DNS record somewhere that points `devicename.yourdomain.net` to a Tailscale IP. You could even automate it, if you really felt like it. Still, _not_ having to do that is worth something, especially given [how much of a pain it is](https://tailscale.com/blog/2021-09-private-dns-with-magicdns/) to manage split-horizon DNS<Sidenote>Which is why this is the Achilles heel of Magic DNS. Immediately upon starting to set up Tailscale we spent an entire morning trying to debug why DNS queries for single-label names on Windows were taking 2+ seconds to resolve. However, since Magic DNS is still officially in beta, I'll give it a pass on that for the time being.</Sidenote> (it's even worse on other platforms, from what I hear.)
|
|
||||||
|
|
||||||
Looking back over these I realize that I might be slightly underselling it: it's hard to overemphasize how well Tailscale _just works_. You kind of have to use it to appreciate it - Tailscale discussions are chock-full of people saying variations on "I never understood why everyone was so crazy about it, I mean it's just a mesh VPN right? There's a bunch of those. But then I tried it and OMG THIS IS THE BEST THING EVER TELL EVERYONE!!!" The attention paid to the little details at every level is just phenomenal. If Apple (old Apple, under Steve Jobs) had decided to go after networking rather than laptops and phones, they might have come up with something like Tailscale.
|
|
||||||
|
|
||||||
Of course, it's not _perfect_. What ever is? I have a few (minor) nitpicks:
|
|
||||||
|
|
||||||
**Cost:** This is probably the one that comes up the most. Tailscale plans start at $5/user/month (except for the free tier, which is only suitable for a single user) and go up from there. Any reasonably-complex network will need the $15/user/month plan, which is (I think) more than any other VPN on this list. You get what you pay for, of course, but that doesn't change the fact that you do pay for it. Absolutely worth it, in my opinion, but it does make it a harder sell to a lot of people.
|
|
||||||
|
|
||||||
**Usermode Wireguard:** Obviously this currently only applies to Linux (and maybe BSD?) as far as I'm aware. Still, it would be nice if Tailscale could make use of kernel-mode Wireguard where available, since otherwise you're leaving throughput on the table. For example, between two fairly beefy machines I get about 680 Mb/s throughput when testing with iPerf. Between one beefy machine and one Synology NAS with a wimpy CPU, I get about 300. Obviously the extent to which this matters depends on what you're trying to do, and it's more than fast enough for most use cases. It just bugs me that it could be better.
|
|
||||||
|
|
||||||
**Data Sovereignty:** (Network sovereignty?) Different people will weight this one differently, but at the end of the day it's true that Tailscale runs a coordination server that is responsible for telling your network who's in it and what kind of access they get. If they decide to add an invisible node that can talk to any of your devices on any port, there's not really anything you can do about it.<Sidenote>Note that this still doesn't mean they can eavsedrop on network traffic between two nodes you _do_ control. Even if you can't make NAT traversal work and end up using a relay, the actual network flows are encrypted with Wireguard. Effectively, each packet is encrypted with its destination's public key. And since private keys are generated on the client, the control server has no ability to decrypt them.</Sidenote> It's not quite as much control over your infrastructure as a third-party SSO service gets, but it's up there. Oh, and I don't think it's officially mentioned on their site, but I've seen comments from Tailscale employees that they can do an on-premise control server for big enough enterprise installs.
|
|
||||||
|
|
||||||
### Headscale
|
|
||||||
|
|
||||||
No discussion of Tailscale would be complete without mentioning [Headscale](https://github.com/juanfont/headscale), a community-driven re-implementation of the Tailscale control plane. You can point the official Tailscale clients at it, although they may require [a bit of hackery](https://github.com/juanfont/headscale/blob/main/docs/windows-client.md) to work properly. And the Tailscale people have said that although it's not officially supported, they are personally in favor of its existence, which I take to mean that they _probably_ won't intentionally break its functionality with an update within the immediate future.
|
|
||||||
|
|
||||||
It solves the cost issue of Tailscale, although it introduces the cost of having to maintain it yourself, which may or may not be something you'd worry about. It does introduce a UX penalty, and I doubt that's going to change any time soon - the Tailscale people don't seem to mind its existence, but I can't see them going very far out of their way to make it easier for something that exists specifically so that people can avoid paying for their service. Still, if you _really really_ want Tailscale, but you simply can't justify the cost, or you're _especially_ paranoid about the control plane, it's worth a shot.
|
|
||||||
|
|
||||||
## The Rest of the Iceberg
|
|
||||||
|
|
||||||
The above options are what I've researched in depth, but they're far from the only mesh VPN solutions out there. I've come across others, but didn't look into them closely for one reason or another - they were either missing some critical component of what we needed, or I didn't discover them until too late, or I just got a weird feeling from them for whatever reason. Still, I'll mention them here in case they happen to be what anybody else is looking for:
|
|
||||||
|
|
||||||
### Tinc
|
|
||||||
|
|
||||||
Tinc is the OG. It's been around since 1998 and still has a community of dedicated users to this day. It does full-mesh, NAT traversal, and even (aparently) some LAN stuff, like ZeroTier.<Sidenote>I don't get the impression it fully emulates Layer 2 the way ZeroTier does, rather it just has the ability to "bridge" LANs together, which I assume just means "forward broadcast traffic over the tunnel." Probably works ok for small LANs, but I'd hate to see how it scales.</Sidenote>
|
|
||||||
|
|
||||||
It doesn't do ACLs, as far as I am aware, which made it a non-starter for us, so that's why it's down here rather than up in the previous section. Moreover, I can't help wondering - if Tinc has been doing this so long, why is it still so niche? Mesh VPNs are obviously great, so why hasn't Tinc eaten the world?
|
|
||||||
|
|
||||||
One possibility (borne out by a few anecdotes that I've seen online) is that Tinc just doesn't perform very well. And I don't just mean in terms of raw bandwidth<Sidenote>Although its bandwidth doesn't seem to be great, from the few benchmarks I've seen.</Sidenote>, I mean everything. How often does NAT traversal fail? How long does it take state changes to propagate through the network? How often does it randomly disconnect without saying anything?
|
|
||||||
|
|
||||||
From a brief glance at its documentation it also seems that it might be a bit of a pain to manage. E.g. the documentation recommends manually distributing configuration by sending config files back and forth, which doesn't sound terribly pleasant.
|
|
||||||
|
|
||||||
### PeerVPN
|
|
||||||
|
|
||||||
I don't really know too much about this one, it just popped up when I was Googling around. It looks like it has the basics, i.e. peer discovery and NAT traversal, and probably not any kind of access control, but the site is extremely minimal so I can't get much of a read on it.
|
|
||||||
|
|
||||||
### FreeLAN
|
|
||||||
|
|
||||||
Much like the above, just something that showed up while I was looking around. It looks to be a bigger project than PeerVPN, or at least the website is a little more fleshed out. I honestly can't quite parse out all of its features - I don't _think_ it does NAT traversal? I can't quite tell for sure, though. The documentation is a little light. Although it does mention that it uses X.509 certificates, which is an instant turnoff for me because messing with X.509 is a _pain_.
|
|
||||||
|
|
||||||
### VPNCloud
|
|
||||||
|
|
||||||
VPNCloud is a little more fully-featured, like the bigger players I've mentioned. It doesn't seem to do access control, so it's not a true contender for our use-case, but it does look like it works fairly well for what it does do. Their site claims that they've gotten multiple gigabits of throughput between m5.large AWS instances (so, not terribly beefy) which is better than pretty much anything else I've seen other than vanilla Wireguard.
|
|
||||||
|
|
||||||
### Netbird
|
|
||||||
|
|
||||||
The first time I ran across [this one](https://netbird.io), it was called "Wiretrustee". A change for the better, I think. It looks to be pretty much exactly "open-source Tailscale", so my guess is it will entirely live or die by how well it executes on that. Obviously Tailscale is great, and Headscale proves that there are people who would like to run the control plane themselves, so there's a market for them. Unfortunately it looks like their monetization scheme is "be Tailscale" (i.e. run a hosted version and charge for anything over a single user), at which point why wouldn't you just use Tailscale?
|
|
||||||
|
|
||||||
### And More
|
|
||||||
|
|
||||||
There's a handy [list](https://github.com/HarvsG/WireGuardMeshes) on Github of Wireguard mesh things, some of which I've already mentioned. And I'm sure even more will continue to pop up like weeds, since everybody seems to want one and a surprisingly large number of people are happy to just sit down and write their own. I guess that's proof that Wireguard made good choices about what problems to address and what to ignore - not an easy task, especially the latter.
|
|
||||||
|
|
||||||
## Where Do We Go From Here
|
|
||||||
|
|
||||||
It's an exciting time in the world of networking. The Tailscale people talk a lot about this on their blog, because of course they do, but the advent of high-performance, low-overhead VPNery has opened up some pretty interesting possibilities in the world of how we interact with computers. Most excitingly it promises something of a return to the Good Old LAN Days, where every device on the network was trusted by default and no one ever worried about things like authentication and encryption, because why would anyone want to do anything unpleasant to your computer? The Internet made that position untenable, but Tailscale and its ilk hope to bring it back again, With some added benefits from modern cryptography. I can't say whether they'll succeed, but if nothing else it's looking like a fun ride.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
title: Imagining A Passwordless Future
|
|
||||||
description: Can we replace passwords with something more user-friendly?
|
|
||||||
date: 2021-04-30
|
|
||||||
draft: true
|
|
||||||
---
|
|
||||||
<script>
|
|
||||||
import Sidenote from '$lib/Sidenote.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
Passwords are the *worst*.
|
|
||||||
|
|
||||||
How many times have you groaned becuase *yet another* password-related thing
|
|
||||||
was messed up? Forgotten passwords, passwords that you're *sure* you wrote
|
|
||||||
down but can't find for some reason, passwords that you definitely *did* make
|
|
||||||
a record of but the site is inexplicably refusing to accept, passwords that
|
|
||||||
get silently truncated because your bank is still using 3DES for some reason,
|
|
||||||
the list goes on. It's constant point of pain for almost everyone, and even
|
|
||||||
after 20+ years of trying to make it work we *still* haven't figured out a
|
|
||||||
foolproof method. Password managers help, but they aren't perfect. How many
|
|
||||||
times have you created a password for a new account somewhere, saved it, and
|
|
||||||
then discovered that your save didn't go through - maybe it didn't meet the
|
|
||||||
requirements (because your 24-character string of gibberish didn't includ a s
|
|
||||||
p e c i a l c h a r a c t e r), or maybe your cable box got hit by
|
|
||||||
lightning just as you clicked Save, or *whatever*. The fact is that passwords
|
|
||||||
are a pain, and it seems to be a pretty intractable problem.
|
|
||||||
|
|
||||||
You know what aren't a pain, or at least not nearly to the same extent? Keys.
|
|
||||||
That's right, physical stick-em-in-a-lock-and-turn metal keys. They've been
|
|
||||||
around since forever,<Sidenote>This is an example sidenote.</Sidenote>
|
|
||||||
and I doubt they'll be going anywhere any time soon.
|
|
||||||
|
|
||||||
I really hate passwords.
|
|
||||||
|
|
||||||
I use them, of course, because I can't not. And I use a password manager, because to my mind that's the current best compromise between being secure and absolutely losing your mind, but it still isn't great. Sometimes my password manager bugs out and refuses to auto-fill the password box, so I have to go hunt it down and copy-paste it in.<Sidenote>If I'm lucky. If I'm unlucky, the site will have disabled pasting into password inputs because "security," and I'm stuck having to type in a 16-character string of gibberish on a mobile phone, because that's how life is.</Sidenote> Other times I'll create a password, the password manager will happily file it away, and then I'll discover that it didn't meet the site's requirements,<Sidenote>Another test</Sidenote> because my auto-generated gibberish string didn't include the *right* special characters, and now I have the wrong password saved.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'Languages: High and Low'
|
|
||||||
description: How high is up?
|
|
||||||
date: 2022-08-19
|
|
||||||
draft: true
|
|
||||||
---
|
|
||||||
|
|
||||||
<script>import Sidenote from '$lib/Sidenote.svelte';</script>
|
|
||||||
|
|
||||||
Here's a fun way to start an argument: Go ask on a programming forum whether C should be considered a "high-level" or "low-level" language. The majority of responses will be along the lines of "low-level, obviously, you have to call `malloc()` all the time", but every once in a while you'll find some crusty old wizard who earned his sripes on MULTICS grousing about how kids these days don't understand what a _real_ low-level language looks like, and back in my day we had to flip individual bits with our teeth, and so on.
|
|
||||||
|
|
||||||
The truth is, asking whether a language is high- or low-level unqualifiedly is sort of like asking whether an object is heavy. Heavy relative to what? The question is open-ended without some frame of reference.<Sidenote>Of course, most people have an implicit frame of reference for this question that resolves to "heavy relative to the objects I commonly encounter in my day-to-day life". As it turns out (in my opinion), most programmers have a similar implicit frame of reference when it comes to languages, which is where you get the disagreement I mentioned earlier.</Sidenote> A boulder is heavy relative to a peanut, but quite light compared to an oil tanker.
|
|
||||||
|
|
||||||
A better question, in my opinion, is whether one language is _higher-_ or _lower-level_ than another, and this is where it gets interesting. Most people will probably agree that higher-level languages are _more abstract_ than lower-level ones, i.e. they take more of the fiddly details of what's actually going on inside the computer and hide them away so that you, the programmer, don't have to worry about them.
|
|
||||||
|
|
||||||
You can probably throw little language-specific details back and forth until the cows come home, but I think there are a few common "break-points" that you can use to group languages into buckets of similarly high- or low-level-ness. To me, they look something like this:
|
|
||||||
|
|
||||||
* **Level 1** langauges give you control over individual registers (just assembly, basically)
|
|
||||||
* **Level 2** languages give you control over memory allocation and raw pointers. E.g. C and C++, and Zig I guess? Also a lot of older languages like Pascal and Ada.
|
|
||||||
* **Level 3** languages handle memory allocation and deallocation for you, but still distinguish between the stack and the heap. Java, C#, and Go fall here, as do Nim, Crystal, probably Pony, V, and others.<Sidenote>You can even make the argument that Rust belongs at this level. Even though it's often referred to as a "systems" language, which most people associate with "low level", it _kind of_ abstracts away the work of allocating and deallocating memory. It just doesn't abstract it quite as _much_ as other languages, since you do still have to think about it.</Sidenote> You can further split this level into those that require a runtime (Java, C#) and those that don't (Go, Nim, Crystal, etc.) Although more properly I guess the latter category just embed their runtime, rather than requiring it to exist separately.
|
|
||||||
* **Level 4** languages give you control over things like intermediate variables and so on - there are probably a lot of sub-levels here, like having to instantiate loop variables vs. having them handled for you,<Sidenote>Or both! Hi Javascript!</Sidenote> but I think this is the core of it. Most dynamic/interpreted languages probably fall here: Python, JS, Ruby, PHP, Perl, etc.
|
|
||||||
* **Level 5** languages don't give you control over any of those. The only one I can think of off the top of my head is SQL, but there are probably others. I have a hunch that at this point you tend to see a lot more domain-specific stuff, since it's easier to abstract away details when you know what the use-case will look like.
|
|
||||||
|
|
||||||
I can also imagine a Level 0 which gives you control over things that aren't currently exposed by the underlying hardware. For instance, you could have a language construct that allows you to "pin" memory regions to a certain cache level, guaranteeing that you can always access it quickly. Or you could attach a "hint" to a branching codepath that allows you to override the CPU's branch predictor, if you know that one case will be overwhelmingly more common than the other. I wonder whether we'll start to see this sort of thing in the future, as Moore's Law continues to slow down and people start looking for more and more ways to squeeze maximum performance out of their hardware.
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
---
|
|
||||||
title: Let's Design A Simpler SocketIO
|
|
||||||
date: 2021-10-16
|
|
||||||
description: SocketIO is packed with features. But do we really need all of them all the time?
|
|
||||||
draft: true
|
|
||||||
---
|
|
||||||
|
|
||||||
Listen, don't get me wrong. SocketIO is great. It provides tons of features,
|
|
||||||
fantastic platform support, is widely deployed by a hugely diverse set of
|
|
||||||
companies, and has been around long enough that it probably has most of the
|
|
||||||
easily-encountered bugs ironed out.
|
|
||||||
|
|
||||||
So why wouldn't you want to use it? Well, a couple of reasons occur to me.
|
|
||||||
One, it's not exactly small. The unpacked library weighs in at just over 1MB,
|
|
||||||
which isn't a lot if it's a core component of your application (e.g. if
|
|
||||||
you're building a real time chat app) but is a bit much if you're only
|
|
||||||
looking for a small subset of its features.
|
|
||||||
|
|
||||||
Two, it's reasonably complex. Again, not insurmountably so, but complex enough
|
|
||||||
that it probably isn't worth hacking together a basic SocketIO client in your
|
|
||||||
REPL of choice if you just want to test something real quick. And on the
|
|
||||||
server side, it's complex enough that you'll probably want to avoid rolling
|
|
||||||
your own if possible. This becomes especially troublesome if you already have
|
|
||||||
a working application and you just want to sprinkle in a little real-time
|
|
||||||
interactivity, rather than building your whole application around that
|
|
||||||
assumption. In my (admittedly limited) experience, the existing SocketIO
|
|
||||||
integrations don't always play very nicely with the existing server
|
|
||||||
frameworks, although if you stick to the most widely-used stuff you're
|
|
||||||
probably fine.
|
|
||||||
|
|
||||||
And honestly, it's just a lot of complexity to introduce if you just want a
|
|
||||||
simple event stream. You could argue that you don't even need websockets for
|
|
||||||
this - Server Sent Events are a thing, as are simple HTTP requests with a
|
|
||||||
streaming response - but in this day and age, the set of solutions to the
|
|
||||||
problem of "persistent communication between server and client" has pretty
|
|
||||||
firmly coalesed around websockets. They're there, they're supported, there
|
|
||||||
are lots of libraries - you might as well just go with the flow.
|
|
||||||
|
|
||||||
## Ok, so what are you saying?
|
|
||||||
|
|
||||||
Basically, that we need something that's _like_ SocketIO but lighter-weight,
|
|
||||||
and solves a more limited set of problems. Specifically, the problem I'm
|
|
||||||
looking to solve is _event streams_ - given a web service and a client, how
|
|
||||||
does the client detect that _things are happening_ in the background so that
|
|
||||||
it can update itself accordingly?
|
|
||||||
|
|
||||||
The use cases for this are pretty extensive. Most obviously, you can use it to
|
|
||||||
implement a notifications system on pretty much any webapp that
|
|
||||||
involves "users want to know when things happen," which is pretty broad.
|
|
||||||
Maybe you're running an ecommerce shop and you want to notify your customers
|
|
||||||
that the item they've had their eye on is back in stock. Or you've just
|
|
||||||
opened up a new promotion and they should check it out. Maybe you're running
|
|
||||||
a website that displays a stock ticker, and you need up-to-the-second data on
|
|
||||||
stock prices. Or you've got a dashboard with some kind of real-time
|
|
||||||
monitoring chart, whatever it's measuring, and you want to keep it up to date
|
|
||||||
with a minimum of overhead. Pub/sub is a powerful concept, which is why
|
|
||||||
people keep re-implementing it. Like I'm doing here. Get over it.
|
|
||||||
|
|
||||||
## But why can't I just use SocketIO for this, again?
|
|
||||||
|
|
||||||
I mean, you _can._ But SocketIO does so much _more_ than simple pub/sub.
|
|
||||||
Connection multiplexing, automatic reconnects, receipt acknowledgements, the
|
|
||||||
list goes on. All of these features are great if, again, you are implementing
|
|
||||||
an instant messenger. They even have a feature called "rooms," which mentions
|
|
||||||
in its documentation that it "makes it easy to implement private messages,"
|
|
||||||
among other things, so it's pretty clear who their target is.
|
|
||||||
|
|
||||||
And it's a great target! Lots of people need instant messaging. Every website
|
|
||||||
in the world seems to pop up a bubble saying "Hi, I'm [friendly-sounding
|
|
||||||
name]! Do you want to talk about giving us money?" 30 seconds after you hit
|
|
||||||
their page. Everyone with a customer-service team has discovered or will soon
|
|
||||||
discover that most issues are easier to resolve over text than over the
|
|
||||||
phone, especially if your CS is outsourced to some foriegn country so your
|
|
||||||
reps all have an accent. SocketIO exists for a reason, and it's a very good
|
|
||||||
reason, and if that's what you need then go for it, knock yourself out.
|
|
||||||
|
|
||||||
But if all you need is a simple event stream with pub/sub semantics, then keep
|
|
||||||
reading, because that's what I want to talk about.
|
|
||||||
|
|
||||||
## Fine. Make your pitch, I'll listen.
|
|
||||||
|
|
||||||
The protocol I'm imagining should solve three basic problems:
|
|
||||||
* Authentication
|
|
||||||
* Connection management (keepalive, automatic reconnects)
|
|
||||||
* ...and the actual pub/sub itself, of course.
|
|
||||||
|
|
||||||
Let's go through each of these in turn.
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
The protocol purists might start to set up a bit of a racket here. Ignore
|
|
||||||
those guys, they suck. Listen, every web-based protocol in the world should
|
|
||||||
at least be _aware_ of the question of authentication. Maybe the awareness
|
|
||||||
should stop at "that's being handled so I don't need to think about it," but
|
|
||||||
at least that much is pretty necessary. I don't know exactly how much Web
|
|
||||||
traffic is authenticated vs. unauthenticated (ask Cloudflare, they might) but
|
|
||||||
according to some quick Googling an Akamai bigwig said in 2019 that 83% of
|
|
||||||
the traffic they see is API traffic. I imagine that API traffic is
|
|
||||||
overwhelmingly authenticated, and when you factor in the fact that a large
|
|
||||||
part of the rest is social media, which is also going to be
|
|
||||||
mostly-authenticated, I imagine you'll end up with somewhere between "a whole
|
|
||||||
lot" and "the vast majority."
|
|
||||||
|
|
||||||
So you need authentication, and websockets don't give it to you. Well, they
|
|
||||||
leave it open, kinda - RFC 6455 says that the websocket opening handshake
|
|
||||||
(which starts with an HTTP request) can include
|
|
||||||
|
|
||||||
> Optionally, other header
|
|
||||||
fields, such as those used to send cookies or request authentication to a
|
|
||||||
server.
|
|
||||||
|
|
||||||
But in practice, this still kinda sucks. It sucks because you can't
|
|
||||||
have _one_ authentication method that's dead-simple and works for everybody.
|
|
||||||
Either you're coming from the browser, in which case you're stuck with
|
|
||||||
session cookies or URL params and that's it, or you're coming from some kind
|
|
||||||
of scripting environment where you'd love to be able to just stick a bearer
|
|
||||||
token in the `Authorization` header like everybody else does, but that's not
|
|
||||||
how the browser does it so tough luck.
|
|
||||||
|
|
||||||
The only solution that works easily with all clients is to put the
|
|
||||||
auth in a URL param. So let's just do that. Unfortunately, that creates a new
|
|
||||||
issue: we can't just use a plain bearer token any more, because now it's in
|
|
||||||
the URL and URL's go all sorts of places - server logs, CDNs, browser address
|
|
||||||
bars, etc. Probably the best thing to do here is to simply sign the URL
|
|
||||||
[a la AWS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#RESTAuthenticationQueryStringAuth).
|
|
||||||
Fortunately, since we're only dealing with a very specific type of request,
|
|
||||||
we don't need to bother with the authenticated headers business that AWS
|
|
||||||
does.
|
|
||||||
|
|
||||||
The browser has very limited capabilities when it comes to modifying the request, so we should probably stick to a signature that can be included directly in the URL as a couple of querystring params.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
---
|
|
||||||
title: Sufficiently Advanced Technology Is Often Distinguishable From Magic
|
|
||||||
description: I see what Arthur C. Clarke was getting at, but I don't think I agree.
|
|
||||||
date: 2022-05-14
|
|
||||||
draft: true
|
|
||||||
---
|
|
||||||
<script>
|
|
||||||
import Sidenote from '$lib/Sidenote.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
Arthur C. Clarke is famous for many things, among them being his dictum that "Sufficiently advanced technology is indistinguishable from magic."<Sidenote>Which I had always thought of as "Clarke's Law", I looked it up when I went to write this and discovered that it's actually Clarke's _Third_ Law. Apparently he has two others. I don't know that I've ever run into them, though. Maybe he should have led with this one?</Sidenote> I'm often happy to take issue with Clarke's opinions, so today I want to put this one on the chopping block.
|
|
||||||
|
|
||||||
Before we get started, a note: the pedantic may argue that by "indistinguishable" he simply means "from the outside," as it were. That is, if you're an untrained citizen of the Galactic Empire or whatever, your knowledge of how a hyperdrive works is so far removed from the reality that it might as well be magic - certainly you couldn't build a new one from scratch, or even fix a broken one. That's as may be, but it's not the interpretation that I want to address here, for two reasons: 1) it's boring, and 2) I don't think that's what Clarke actually meant when he coined the phrase.
|
|
||||||
|
|
||||||
In fact, I think Clarke was thinking more along these lines: Advanced technology (by which I mean, technology that is more advanced than anything we have today) and magic both postulate that the impossible is possible. In the case of non-fundamental advances you can kind of see how this might come about, like an economically feasible Mach 3 passenger aircraft, for instance. But as technology gets further and further from the current state of the art, and encompasses more and more that's not just "we don't know if it's possible" but "we actually think it's impossible" the divorce from reality becomes so complete that it's _just as profound_ as a wizard who levitates and summons fireballs out of the air by sheer power of will.<Sidenote>It's also interesting to note that Clarke wrote primarily _near-future_ sci-fi - his most famous work is set _twenty years ago_, for goodness sake! But perhaps that has something to do with his apparent disdain for grander flights of sci-fi fancy.</Sidenote>
|
|
||||||
|
|
||||||
That's what I'm disagreeing with. In particular I take issue with the term "indistinguishable," because it implies there is _no basis_ by which the technology in question can be distinguished from magic. I think, however, that there usually _is_ such a distinction, and in fact it's quite easy to make. And it doesn't have anything to do with _how_ advanced the technology is, which may be why it didn't occur to Clarke, but this is my blog so I get to be as nitpicky as I like.<Sidenote>Yes, I know that I just implied above that I'm above pedantry, so what? Still my blog, and I don't have to be consistent unless I want to.</Sidenote>
|
|
||||||
|
|
||||||
To me, the distinction between technology and magic has always hinged more on the _mechanism_ of the thing than its degree of connection with reality. To wit: Does the [magic, technology] constitute an _intrinsic_ or _extrinsic_ means for someone to influence the world around them? If intrinsic, then it's magic. If extrinsic, then it's technology.
|
|
||||||
|
|
||||||
When I say "intrinsic" and "extrinsic" I'm mostly thinking of the origin of the power. An intrinsic power is something natural that the user is born with, while an extrinsic power is conferred by artifice - usually some sort of constructed device. There are definitely edge cases that blur the line between these distinctions, but they're mostly pretty clear when you're looking at individual examples.
|
|
||||||
|
|
||||||
For example, a wizard who moves objects by focusing his mind and exerting his will on the universe is using magic. A starship captain who moves objects using a tractor beam is using technology, because the tractor beam constitutes an _extrinsic_ means of interacting with the world. The only intrinsic tools that the starship captain uses are (presumably) hands to manipulate controls of some sort.<Sidenote>There could, of course, be some kind of computer-mediated direct-brain interface, but that definitely still counts as technology since, again, the computer is merely reacting to _normal human actions_, in this case thoughts.</Sidenote>
|
|
||||||
|
|
||||||
Interestingly, by this definition there's no need for magic to be restricted to a certain subset of the population. You could easily imagine a world where _everyone_ has the power of telekinesis or something, or even one of many powers, and in fact it would be quite interesting to explore the ramifications of such a world. Mostly, however, stories that portray magic as we've defined it portray it as something available to only a few.
|
|
||||||
|
|
||||||
## Original form
|
|
||||||
|
|
||||||
Clarke's Law<Sidenote>Actually it's Clarke's Third Law, there are two others. Shows what I know. I will, however, continue to refer to it as "Clarke's Law" for the time being, since it's easier to type and I'm lazy.</Sidenote>, i.e. "Sufficiently advanced technology is indistinguishable from magic," is a well-known dictum in science fiction. I've never had a significant reason to disagree with it in the past, but recently I read _Elder Race_ by Adrian Tchaikovsky and it got me thinking. The upshot is, I've come to the conclusion that (within the world of fiction, of course) sufficiently advanced technology actually _is_ distinguishable from magic, in fact almost always so. Moreover, the distinction is really quite simple: Does the "magic" operate through _extrinsic_ or _intrinsic_ means? Does the magic-user act by operating a device that acts on the natural world, or does he simply exert his will and the world conforms to his desire? If the former, it's probably technology, and if the latter, it's probably magic.
|
|
||||||
|
|
||||||
Before I get started though, the book: _Elder Race_ is quite enjoyable, and not very long either, so you should definitely read it if you're into either sci-fi _or_ fantasy, because it manages to be both. In the interest of avoiding too many spoilers I won't go into too much detail, but the main conceit of the book is spoiled by the jacket blurb anyway, so I won't worry too much about that one. In brief: _Elder Race_ is an enjoyable and fairly in-depth exploration of Clarke's Law. It spends a lot of time considering not just the basic aspects (Look, flying machines! Must be magic!) but deeper questions, like: how would you even go about explaining technology to someone from an un-technological society?
|
|
||||||
|
|
||||||
Unsurprisingly, it comes away with more or less the conclusion that you can't really: the technologically unaware will continue to regard your flying machines as magical conveyances held aloft by arcane powers, your radio as deep wizardry that allows you to commune with distant spirits, and so on. You can try to explain it all you like, but if you say "science" your listener will hear "magic," and if you say "it's just an understanding of natural forces built up over generations" they will hear "it's just hidden knowledge of the secrets of the universe, handed down from the ancients." There is a communications barrier that is, according to this view, insurmountable without starting at the very beginning and working your way up.
|
|
||||||
|
|
||||||
Now, this may or may not be true, but I'd like to take issue with the more general formulation of Clarke's Law. I've always taken the "indistinguishable" bit to mean that _no one_ can distinguish the two, not just that _those unfamiliar with technology_ can make the distinction. I don't think that's the case, though. I think that you _can_ distinguish between magic and technology, and that the distinction is trivial at least in many cases. The question you can usually ask, and often get a clear answer to, is: "Does the [magic/technology] operate by means of devices, or does it rely on internal powers of the user?" if the former, it's technology. If the latter, it's magic.
|
|
||||||
|
|
||||||
Let's take some examples. On the magic side, think of some of the classic swords-and-sorcery canon: _Earthsea_, _Wheel of Time_<Sidenote>Much as I dislike it, it's undeniably genre-defining.</Sidenote>, _Prydain Chronicles_, _Chronicles of Amber_, _Belgariad_, and so on.<Sidenote>You might notice that I've skipped LOTR here: don't worry, it will show up later.</Sidenote> All of these have in common that magic is effected by a _direct act of will_. There is no mediating device or artifice, the magician simply exerts his will on the universe. There may be techniques involved, or limits to what the magic can accomplish, but there's fundamentally just some direct connection between the wizard's will and the natural world that other people don't have, and that's what makes him a wizard.
|
|
||||||
|
|
||||||
On the other hand, we have sci-fi. Note that a sci-fi story's position on the technology/magic scale is distinct from where it sits on the "sci-fi hardness" scale, although the two are often correlated: sci-fi that incorporates magic, as defined here, tends to be on the softer side. Still, I can name some examples of sci-fi that's unquestionably pure technology. A lot of Heinlein's stuff qualifies, for example _The Moon is a Harsh Mistress_. Bujold's "Vorkosiverse" (I don't think that's the official name) also qualifies, as far as I can remember, and serves as a good example of the distinction between "soft" sci-fi and "magical" sci-fi: it's very soft, but doesn't incorporate any magic. _Ender's Game_. _Snow Crash_ (ok, that one wasn't too hard, most near-future sci-fi is necessarily free of magic.) Plenty of short stories, although for some reason I can't think of any right now except for _Nerves_.
|
|
||||||
|
|
||||||
I'm cherry-picking here, of course. That's ok though, I'm not intending these examples to be an argument, more a set of examples that you can nod your head to and think "Yes, these clearly deal with magic/technology." But there are plenty of things that aren't so clear-cut, so let's take a look at those and see what we make of them.
|
|
||||||
|
|
||||||
# Built-in technology
|
|
||||||
|
|
||||||
What do you call it when the magic-or-technology operates by means of something that _is_ a natural part of the person or animal? What if there was a massive multi-generational genetic engineering effort that resulted in a race of psionic people? Is that magic, or technology?
|
|
||||||
|
|
||||||
This one's tough, but I think I have to come down on the side of "it's still technology."
|
|
||||||
|
|
||||||
Sci-fi, but actually magic
|
|
||||||
- Psi in Federation stories
|
|
||||||
- MCU
|
|
||||||
- Star Wars
|
|
||||||
- Cloak of Aesir
|
|
||||||
- Madeleine L'Engle stuff
|
|
||||||
- The Stars My Destination / The Demolished Man
|
|
||||||
|
|
||||||
Magic, but really technology
|
|
||||||
- Harry Potter?
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
---
|
|
||||||
title: Thoughts on Vue vs Svelte
|
|
||||||
description: They're more similar than they are different, but they say the most bitter enemies are those who have the fewest differences.
|
|
||||||
date: 2023-06-29
|
|
||||||
draft: true
|
|
||||||
---
|
|
||||||
<script>
|
|
||||||
import Dropcap from '$lib/Dropcap.svelte';
|
|
||||||
import Sidenote from '$lib/Sidenote.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
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<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>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
|
|
||||||
|
|
||||||
Overall I think I favor Vue here. Both Vue and Svelte expect you to write most of your code in "single-file components", which are collections of markup, style, and logic<Sidenote>Much like a traditional HTML page.</Sidenote> that work together to describe the appearance and behavior of a component. But naturally, they do it slightly differently. Vue adds custom vue-specific attributes directly to the HTML elements, such as:
|
|
||||||
```markup
|
|
||||||
<div v-if="items.length">
|
|
||||||
<p>Please choose an item.</p>
|
|
||||||
<ul>
|
|
||||||
<li v-for="item in items">{{ item.name }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<p>There are no items available.</p>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
While Svelte takes the more common approach of wrapping bits of markup in its own templating constructs:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
{#if items.length}
|
|
||||||
<div>
|
|
||||||
<p>Please choose an item</p>
|
|
||||||
<ul>
|
|
||||||
{#each items as item}
|
|
||||||
<li>{item.name}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div>
|
|
||||||
<p>There are no items available.</p>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
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.<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:
|
|
||||||
|
|
||||||
## Data Binding
|
|
||||||
|
|
||||||
I give this one to Svelte overall, although Vue has a few nice conveniences going for it.
|
|
||||||
|
|
||||||
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="`Save ${itemsCount} items`">`.
|
|
||||||
|
|
||||||
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 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" />`
|
|
||||||
```
|
|
||||||
But _inside_ the child component:
|
|
||||||
```js
|
|
||||||
export default {
|
|
||||||
props: ['modelValue'],
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
methods: {
|
|
||||||
doThing() {
|
|
||||||
this.$emit('update:modelValue', newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Admittedly, the Svelte approach does lead more gracefully into more complex scenarios where you need to capture the actual JS event: it just gets passed to the function. Vue kind of capitulates on consistency here and _also_ lets you pass the name of a function to an event handler, which is then called with the event as an argument. _Oooor_, you can reference the event via the special variable `$event`, which is convenient but feels a bit shoehorned in.
|
|
||||||
|
|
||||||
I'm ragging on Vue for its inconsistency here but I should note that I still do prefer the Vue approach, warts and all. "A foolish consistency is the hobgoblin of small minds," after all, and Vue's syntax is just so _convenient_. Besides, it optimizes for the 95% of the time I don't care about capturing the event, because realistically when am I going to want to do that? In both Vue and Svelte, all the traditional use cases for capturing an event are solved in other ways:
|
|
||||||
* You don't usually need `event.target`, because you can just give yourself a handle to the element directly (via `ref` in Vue, `bind:this=` in Svelte)
|
|
||||||
* You don't need to use it to get the value of an input (common with events like `change`), because you're just going to use a two-way binding for that
|
|
||||||
* In Vue, you don't even need it to check for modifier keys, because Vue gives you special syntax for this like `@event.shift`. (Svelte doesn't have an equivalent for this, so advantage Vue here again.)
|
|
||||||
|
|
||||||
You really only need to access the event when you're doing something more exotic, e.g. handling a bubbling event on a parent element and you need to check which child was actually the target, which does happen but again not the _majority_ of the time.
|
|
||||||
|
|
||||||
## Declaring Reactive Values
|
|
||||||
|
|
||||||
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. 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
|
|
||||||
$: let fullname = `${firstname} ${lastname}`;
|
|
||||||
$: console.log(firstname, lastname);
|
|
||||||
```
|
|
||||||
would re-run any time `firstname` or `lastname` is updated, assuming those are reactive values to begin with.
|
|
||||||
|
|
||||||
Overall I tend to prefer the simplicity of Svelte's approach to reactivity, although I do find the `$:` syntax a little weird. It may be valid JS, but it's not valid JS that anybody actually _uses_. Moreover its official meaning doesn't have anything to do with what Svelte is using it for, so the fact that iT's vAliD jAVaSCriPt doesn't really do much for me. I think Vue's `computed` and `watch` options are much more obvious, if only from how they're named.
|
|
||||||
|
|
||||||
That said, I don't have any better ideas for marking reactive statements in Svelte, especially given that sometimes you _want_ a statement to ignore updates even if it does reference a value that might be updated. So maybe this is just one of those compromises you have to make.
|
|
||||||
|
|
||||||
## Code Structure
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default {
|
|
||||||
import {doThing, mungeData} from './otherfile.js';
|
|
||||||
|
|
||||||
// ...
|
|
||||||
computed: {
|
|
||||||
mungeData,
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
methods: {
|
|
||||||
doThing,
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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) 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 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
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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} />
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { postData } from './posts.js';
|
|
||||||
|
|
||||||
export async function get() {
|
|
||||||
return {body: postData[0]};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { dev } from '$app/env';
|
|
||||||
const posts = import.meta.globEager('./_posts/*.svx');
|
|
||||||
|
|
||||||
export let postData = [];
|
|
||||||
|
|
||||||
for (const path in posts) {
|
|
||||||
// skip draft posts in production mode
|
|
||||||
if (!dev && posts[path].metadata.draft) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<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>
|
|
||||||
12
src/styles/main.css
Normal file
12
src/styles/main.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
@import '@fontsource-variable/figtree';
|
||||||
|
@import 'reset.css';
|
||||||
|
@import 'vars.css';
|
||||||
|
@import 'prose.css';
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Figtree Variable', sans-serif;
|
||||||
|
font-weight: 350;
|
||||||
|
font-size: var(--content-size);
|
||||||
|
line-height: var(--content-line-height);
|
||||||
|
color: var(--content-color);
|
||||||
|
}
|
||||||
55
src/styles/prose.css
Normal file
55
src/styles/prose.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
@import '@fontsource-variable/baskervville';
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'Baskervville Variable', serif;
|
||||||
|
font-weight: 650;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: hsl(0 0% 27%);
|
||||||
|
letter-spacing: 0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
font-size: 2.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5, h6 {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/styles/reset.css
Normal file
22
src/styles/reset.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/* 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%;
|
||||||
|
}
|
||||||
19
src/styles/vars.css
Normal file
19
src/styles/vars.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
:root {
|
||||||
|
--content-size: 1.25rem;
|
||||||
|
--content-size-sm: 1rem;
|
||||||
|
--content-line-height: 1.5;
|
||||||
|
--content-width: 52.5rem;
|
||||||
|
--content-padding: 0.65rem;
|
||||||
|
--content-color: hsl(0deg 0% 20%);
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dracula Theme originally by Zeno Rocha [@zenorocha]
|
|
||||||
* https://draculatheme.com/
|
|
||||||
*
|
|
||||||
* Ported for PrismJS by Albert Vallverdu [@byverdu]
|
|
||||||
*/
|
|
||||||
|
|
||||||
code[class*="language-"],
|
|
||||||
pre[class*="language-"] {
|
|
||||||
color: #f8f8f2;
|
|
||||||
background: none;
|
|
||||||
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
|
|
||||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
|
||||||
text-align: left;
|
|
||||||
white-space: pre;
|
|
||||||
word-spacing: normal;
|
|
||||||
word-break: normal;
|
|
||||||
word-wrap: normal;
|
|
||||||
-moz-tab-size: 4;
|
|
||||||
-o-tab-size: 4;
|
|
||||||
tab-size: 4;
|
|
||||||
-webkit-hyphens: none;
|
|
||||||
-moz-hyphens: none;
|
|
||||||
-ms-hyphens: none;
|
|
||||||
hyphens: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code blocks */
|
|
||||||
pre[class*="language-"] {
|
|
||||||
padding: 1em;
|
|
||||||
margin: 1em 0;
|
|
||||||
overflow: auto;
|
|
||||||
border-radius: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
:not(pre) > code[class*="language-"],
|
|
||||||
pre[class*="language-"] {
|
|
||||||
background: #282a36;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline code */
|
|
||||||
:not(pre) > code[class*="language-"] {
|
|
||||||
padding: .1em;
|
|
||||||
border-radius: .3em;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.comment,
|
|
||||||
.token.prolog,
|
|
||||||
.token.doctype,
|
|
||||||
.token.cdata {
|
|
||||||
color: #6272a4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.punctuation {
|
|
||||||
color: #f8f8f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.namespace {
|
|
||||||
opacity: .7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.property,
|
|
||||||
.token.tag,
|
|
||||||
.token.constant,
|
|
||||||
.token.symbol,
|
|
||||||
.token.deleted {
|
|
||||||
color: #ff79c6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.boolean,
|
|
||||||
.token.number {
|
|
||||||
color: #bd93f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.selector,
|
|
||||||
.token.attr-name,
|
|
||||||
.token.string,
|
|
||||||
.token.char,
|
|
||||||
.token.builtin,
|
|
||||||
.token.inserted {
|
|
||||||
color: #50fa7b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.operator,
|
|
||||||
.token.entity,
|
|
||||||
.token.url,
|
|
||||||
.language-css .token.string,
|
|
||||||
.style .token.string,
|
|
||||||
.token.variable {
|
|
||||||
color: #f8f8f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.atrule,
|
|
||||||
.token.attr-value,
|
|
||||||
.token.function,
|
|
||||||
.token.class-name {
|
|
||||||
color: #f1fa8c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.keyword {
|
|
||||||
color: #8be9fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.regex,
|
|
||||||
.token.important {
|
|
||||||
color: #ffb86c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.important,
|
|
||||||
.token.bold {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.entity {
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/* ### 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;
|
|
||||||
--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 */
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { mdsvex } from 'mdsvex';
|
|
||||||
import staticAdapter from '@sveltejs/adapter-static';
|
|
||||||
import svp from 'svelte-preprocess';
|
|
||||||
import slug from './src/lib/slug.js';
|
|
||||||
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
extensions: ['.svelte', '.svx'],
|
|
||||||
preprocess: [
|
|
||||||
mdsvex({
|
|
||||||
layout: './src/lib/Post.svelte',
|
|
||||||
rehypePlugins: [slug],
|
|
||||||
}),
|
|
||||||
svp.scss(),
|
|
||||||
],
|
|
||||||
kit: {
|
|
||||||
// hydrate the <div id="svelte"> element in src/app.html
|
|
||||||
adapter: staticAdapter(),
|
|
||||||
prerender: {
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
281
tmp/crane.svg
281
tmp/crane.svg
@@ -1,281 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,99 +0,0 @@
|
|||||||
<!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™</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>
|
|
||||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strictest",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@components/*": ["./src/components/*"],
|
||||||
|
"@layouts/*": ["./src/layouts/*"],
|
||||||
|
"@lib/*": ["./src/lib/*"],
|
||||||
|
"@styles/*": ["./src/styles/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user