Switch over to WebAssembly, Rust and Yew #35

Merged
BlakeRain merged 87 commits from yew-static into main 2023-08-30 18:01:40 +00:00
230 changed files with 17287 additions and 18712 deletions

View File

@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

View File

@ -14,24 +14,35 @@ jobs:
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 16
node-version: 18
- name: Configure Cache
uses: actions/cache@v2
with:
path: |
${{ github.workspace }}/node_modules
${{ github.workspace }}/public/content/**/optimized
${{ github.workspace }}/public/content/images.sha256.json
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}
- name: Install Dependencies
run: yarn install
- name: Build the Website
run: yarn build
- name: Export the Website
run: yarn export
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Install Trunk
uses: jetli/trunk-action@v0.4.0
with:
version: "v0.17.5"
- name: Build Hydrating Application
run: trunk build --release --features hydration
- name: Perform Site Generation
run: cargo run --release --features static --bin site-build
- name: Synchronize with S3 Bucket
uses: jakejarvis/s3-sync-action@v0.5.1
with:
@ -41,7 +52,8 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: "eu-west-1"
SOURCE_DIR: "out"
SOURCE_DIR: "output"
- name: Create CloudFront Invalidation
uses: awact/cloudfront-action@master
env:

49
.gitignore vendored
View File

@ -1,46 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Test data
/public/data
/public/feeds
/public/robots.txt
/public/sitemap.xml
/public/sitemap-0.xml
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
/.netlify
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# Optimized images
/public/content/**/optimized
/public/content/images.sha256.json
/dist
/node_modules
/output
/target

71
.swcrc Normal file
View File

@ -0,0 +1,71 @@
{
"jsc": {
"parser": {
"syntax": "ecmascript",
"jsx": false
},
"target": "es2017",
"loose": false,
"minify": {
"compress": {
"arguments": false,
"arrows": true,
"booleans": true,
"booleans_as_integers": false,
"collapse_vars": true,
"comparisons": true,
"computed_props": true,
"conditionals": true,
"dead_code": true,
"directives": true,
"drop_console": false,
"drop_debugger": true,
"evaluate": true,
"expression": false,
"hoist_funs": false,
"hoist_props": true,
"hoist_vars": false,
"if_return": true,
"join_vars": true,
"keep_classnames": false,
"keep_fargs": true,
"keep_fnames": false,
"keep_infinity": false,
"loops": true,
"negate_iife": true,
"properties": true,
"reduce_funcs": false,
"reduce_vars": false,
"side_effects": true,
"switches": true,
"typeofs": true,
"unsafe": false,
"unsafe_arrows": false,
"unsafe_comps": false,
"unsafe_Function": false,
"unsafe_math": false,
"unsafe_symbols": false,
"unsafe_methods": false,
"unsafe_proto": false,
"unsafe_regexp": false,
"unsafe_undefined": false,
"unused": true,
"const_to_let": true,
"pristine_globals": true
},
"mangle": {
"toplevel": false,
"keep_classnames": false,
"keep_fnames": false,
"keep_private_props": false,
"ie8": false,
"safari10": false
}
}
},
"module": {
"type": "es6"
},
"minify": true,
"isModule": true
}

View File

@ -1,3 +0,0 @@
{
"print.colourScheme": "Atelier Dune"
}

2666
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

105
Cargo.toml Normal file
View File

@ -0,0 +1,105 @@
[package]
name = "site"
version = "2.0.0"
edition = "2021"
publish = false
[profile.release]
lto = true
opt-level = 'z'
[features]
hydration = [
"yew/hydration"
]
static = [
"atom_syndication",
"chrono",
"rss",
"yew/ssr"
]
[[bin]]
name = "site-build"
required-features = [
"static",
]
[[bin]]
name = "site"
[dependencies]
async-trait = { version = "0.1" }
enum-iterator = { version = "1.4" }
gloo = { version = "0.10" }
include_dir = { version = "0.7" }
log = { version = "0.4" }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
thiserror = { version = "1.0" }
wasm-bindgen = { version = "0.2" }
yew = { version = "0.20" }
yew-hooks = { version = "0.2" }
yew-router = { version = "0.17" }
macros = { path = "./macros" }
model = { path = "./model" }
[dependencies.time]
version = "0.3"
features = [
"formatting",
"local-offset",
"macros",
"parsing",
"serde",
"wasm-bindgen"
]
[dependencies.yew_icons]
version = "0.7"
features = [
"BootstrapDot",
"BootstrapGithub",
"BootstrapLightningChargeFill",
"BootstrapMastodon",
"HeroiconsSolidInformationCircle",
"HeroiconsSolidQuestionMarkCircle",
"LucideAlertTriangle",
"LucideBug",
"LucideCheck",
"LucideCheckCircle",
"LucideFlame",
"LucideLink",
"LucideList",
"LucideMenu",
"LucidePencil",
"LucideRss",
"LucideX"
]
[dependencies.web-sys]
version = "0.3"
features = [
"Document",
"DomRect",
"Element",
"IntersectionObserver",
"IntersectionObserverEntry",
"ScrollBehavior",
"ScrollToOptions",
"Window"
]
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = { version = "0.4" }
wasm-logger = { version = "0.2" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
atom_syndication = { version = "0.12", optional = true }
chrono = { version = "0.4", optional = true, features = ["clock"] }
rss = { version = "2.0", optional = true }
tokio = { version = "1.32", features = ["full"] }
env_logger = { version = "0.10" }

133
README.md
View File

@ -1,127 +1,38 @@
# blakerain.com
![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB)
![Next JS](https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white)
![SASS](https://img.shields.io/badge/SASS-hotpink.svg?style=for-the-badge&logo=SASS&logoColor=white)
![AWS](https://img.shields.io/badge/AWS-%23FF9900.svg?style=for-the-badge&logo=amazon-aws&logoColor=white)
![AmazonDynamoDB](https://img.shields.io/badge/DynamoDB-4053D6?style=for-the-badge&logo=Amazon%20DynamoDB&logoColor=white)
![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)
![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white)
![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=white)
This repository contains the sources for my website: [blakerain.com]. The website is built using [Next.js]. It also
This repository contains the sources for my website: [blakerain.com]. The website is built using [Yew]. It also
includes some analytics code that is written in Rust and runs in AWS. The website is stored in an [S3] bucket and served
using the AWS [CloudFront] CDN. Deployment from this repository is performed by a GitHub workflow.
## Analytics
## Cargo Features
The analytics for the site is quite simple, consisting of a couple of AWS [lambda] functions and a [DynamoDB] table. The
two lambda functions provide both the API and a back-end trigger function to perform accumulation.
There are a number of Cargo feature flags that are used during development and during release
builds. These features are as follows:
![Layout](https://github.com/BlakeRain/blakerain.com/blob/main/public/content/site-analytics/analytics-layout.drawio.png?raw=true)
- `static` feature is set when we want to build the static rendering application, called
`site-build`, which will generate the static HTML pages for the site.
- `hydration` feature is set when we're building the WebAssembly for hydration into a statically
rendered page.
The domain `pv.blakerain.com` is aliased to an AWS [API Gateway] domain. The domain is mapped to an API which allows
`GET` and `POST` requests and forwards these requests to the [api] Lambda function. The Lambda function receives these
HTTP `GET` or `POST` requests encoded as JSON (see the [input format] for a Lambda function for a proxy integration).
The Lambda function processes the request and then returns an HTTP response (see the [output format]).
During development, neither `static` nor `hydration` are set. This allows commands like `trunk
serve` to serve the WebAssembly and nicely rebuild upon file changes and so on.
The lambda function response to the following resources:
During a release build, we first build the site using `trunk build` with the `hydration` feature
enabled. This will build a WebAssembly module with the hydration support. Afterwards, we use
`cargo run` to run the `site-build` app with the `static` feature set, which allows us to generate
all the static pages.
| Method | URL | Description |
|--------|-----------------------|--------------------------------------------|
| `GET` | `/pv.gif` | Records a page view |
| `POST` | `/append` | Update a page view record |
| `POST` | `/api/auth/signin` | Authenticate access to the analytics |
| `POST` | `/api/views/week` | Get page view stats for a specific week |
| `POST` | `/api/views/month` | Get page view stats for a specific month |
| `POST` | `/api/browsers/week` | Get browser stats for a specific week |
| `POST` | `/api/browsers/month` | Get browser stats for a specific month |
| `POST` | `/api/pages/week` | Get the total page count for a given week |
| `POST` | `/api/pages/month` | Get the total page count for a given month |
```
trunk build --release --features hydration
cargo run --release --features static --bin site-build
```
### Recording Page Views
When a visitor loads the site, if they visit a page that includes the `<Analytics>` component, a request is made to the
`pv.blakerain.com` domain. Specifically, it tries to load an image from `https://pv.blakerain.com/pv.gif`. The URL
includes a query-string that encodes the various information about the site visit.
When the `GET` request for `pv.gif` image is received by the lambda function, it attempts to extract meaningful values
from the query string, and should it find any, record them as a page view into the DynamoDB table. The record for a page
view contains the following items:
| Field | Type | Description |
|------------------|----------|----------------------------------------------------------|
| `Path` | `String` | The path to the page being visited |
| `Section` | `String` | The string `view-` followed by the UUID of the page view |
| `Time` | `String` | The `OffsetDateTime` for the page view |
| `UserAgent` | `String` | The user agent string for the visitor's web browser |
| `ViewportWidth` | `Number` | The width of the viewport (i.e.: size of the window) |
| `ViewportHeight` | `Number` | The height of the viewport |
| `ScreenWidth` | `Number` | The width of the screen |
| `ScreenHeight` | `Number` | The height of the screen |
| `Timezone` | `String` | The visitor's timezone |
| `Referrer` | `String` | The referrer for the page view (e.g. `google.com`) |
| `Duration` | `Number` | The number of seconds the visitor has been on this page |
| `Scroll` | `Number` | The maximum distance that was scrolled (as a percentage) |
Of the above fields, the only fields that are required are the `Path`, `Section` and `Time` fields. All other fields are
entirely optional, and are only included in the record if they are present in the query-string given in the `GET`
request and can be parsed.
Once the `<Analytics>` component has mounted, it adds event listeners for the `scroll`, `visibilitychange` and
`pagehide` events. When the visitor scrolls the page, the `Analytics` component keeps track of the maximum scroll
distance. When the visibility of the page is changed, and the page is no longer visible, we also record the time at
which the page was hidden. Once the page is visible again, we record the total amount of time that the page was hidden.
This allows us to calculate the total amount of time that the page was visible.
When the page is hidden, or the `<Analytics>` component is unmounted, we send an update to the analytics record using a
`POST` request to the `/append` URL. This allows us to update the duration of a visit to the page and the maximum scroll
distance. The `POST` request send to the `/append` URL contains a JSON object with the following fields:
| Field | Type | Description |
|------------|----------|-----------------------------------------------------|
| `path` | `String` | The path to the page being visited |
| `uuid` | `String` | The UUID of the page view |
| `duration` | `Number` | The updated duration spent on the page (in seconds) |
| `scroll` | `Number` | The maximum scroll distance (as a percentage) |
In order to transmit updates to the page view, we use the `Navigator.sendBeacon` function (see the MDN documentation for
[sendBeacon]). Using the `sendBeacon` function allows us to send data, even when the browser is about to unload the
page.
### Accumulating Page Views
Once a new page view has been recorded in the DynamoDB table, I also want to update the accumulation of these page
views in a number of dimensions. The following table describes the dimensions that we want to record. This is given as
the subject (such as the page being visited, or the site as a whole) and the time-frame being accumulated (daily, weekly,
etc). The table also provides an example of the primary key and hash key used in the DynamoDB table for this recording.
| Object | Time-frame | Primary Key | Hash key | Description |
|-------------|------------|----------------------------|---------------------|--------------------------------------------------------|
| Total Views | Day | `page-view-day-2022-12-02` | `/page` | Number of views for a specific day (2nd December 2022) |
| | Week | `page-view-week-2022-48` | `/page` | Number of views for a specific week (week 48, 2022) |
| | Month | `page-view-month-2022-12` | `/page` | Number of views for a specific month (December 2022) |
| Page | Day | `/post/some-blog-post` | `Day-2022-12-02-18` | Number of views of page at hour of day (here at 18:00) |
| | Week | `/post/some-blog-post` | `Week-2022-48-05` | Number of views of page on day of week |
| | Month | `/post/some-blog-post` | `Month-2022-12-02` | Number of views of page on day of month |
| Entire Site | Day | `/site` | `Day-2022-12-02-18` | Number of views of page at hour of day (here at 18:00) |
| | Week | `/site` | `Week-2022-48-05` | Number of views of page on day of week |
| | Month | `/site` | `Month-2022-12-02` | Number of views of page on day of month |
Accumulating this data for each page view requires performing nine updates to the DynamoDB table. Performing these
updates whenever a request is made to the `pv.gif` image by the client is less than ideal. For this reason, a [trigger]
Lambda function is included which receives events from the DynamoDB table. If the event received by the trigger function
is the insertion of a new page view record, the function performs the above record updates.
[blakerain.com]: https://blakerain.com
[next.js]: https://nextjs.org
[s3]: https://aws.amazon.com/s3/
[cloudfront]: https://aws.amazon.com/cloudfront/
[lambda]: https://aws.amazon.com/lambda/
[dynamodb]: https://aws.amazon.com/dynamodb/
[api gateway]: https://aws.amazon.com/api-gateway/
[api]: https://github.com/BlakeRain/blakerain.com/blob/main/lambda/src/bin/api.rs
[trigger]: https://github.com/BlakeRain/blakerain.com/blob/main/lambda/src/bin/trigger.rs
[input format]: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
[output format]: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format
[sendbeacon]: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
[blakerain.com]: https://blakerain.com/
[Yew]: https://yew.rs/
[S3]: https://aws.amazon.com/s3/
[CloudFront]: https://aws.amazon.com/cloudfront/

9
Trunk.toml Normal file
View File

@ -0,0 +1,9 @@
[[hooks]]
stage = "pre_build"
command = "bash"
command_arguments = [ "tools/prepare.sh" ]
[[hooks]]
stage = "post_build"
command = "node"
command_arguments = [ "tools/finalize.js" ]

3
build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=content");
}

View File

@ -1,339 +0,0 @@
//
// Site Analytics
//
// This module provides an `Analytics` component that embeds an image into the page which records analytics data.
//
// More information about this can be found here: https://blakerain.com/disclaimer#analytics
//
import React, { useEffect, useState } from "react";
// Generate a UUID for a page request
//
// I think I got this from: https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
const uuidv4 = (): string => {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => {
const n = parseInt(c, 10);
return (
n ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (n / 4)))
).toString(16);
});
};
// We prefer all paths to start with a forward slash
const ensureStartSlash = (path: string): string =>
path.startsWith("/") ? path : "/" + path;
// Check if the hostname looks a bit like an IP address
const isIPAddressLike = (host: string): boolean =>
/[0-9]+$/.test(host.replace(/\./g, ""));
// Clear up the referrer, removing any excessive components
const cleanReferrer = (url: string): string =>
url
.replace(/^https?:\/\/((m|l|w{2,3})([0-9]+)?\.)?([^?#]+)(.*)$/, "$4")
.replace(/^([^/]+)$/, "$1");
// Get the distance scrolled through the document
const getPosition = (): number => {
try {
const doc = window.document.documentElement;
const body = window.document.body;
return Math.min(
100,
5 *
Math.round(
(100 * (doc.scrollTop + doc.clientHeight)) / body.scrollHeight / 5
)
);
} catch {
return 0;
}
};
// The dimensions of the viewport and screen
interface Dimensions {
viewportWidth: number;
viewportHeight: number;
screenWidth: number;
screenHeight: number;
}
// Get the dimensions of the viewport and screen
const getDimensions = (): Dimensions => ({
viewportWidth: window.innerWidth || 0,
viewportHeight: window.innerHeight || 0,
screenWidth: (window.screen && window.screen.width) || 0,
screenHeight: (window.screen && window.screen.height) || 0,
});
// Get the TZ
const getTimeZone = (): string | undefined => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return undefined;
}
};
// The parameters that we pass to our analytics API
interface AnalyticsParams {
uuid: string;
path: string;
ua: string;
viewport_width: number;
viewport_height: number;
screen_width: number;
screen_height: number;
referrer: string;
timezone?: string;
duration?: number;
scroll?: number;
}
// Encode a value as a parameter for the API call query-string. This handles the encoding of numbers, strings,
// or missing fields that are 'undefined'.
const encodeParamValue = (
value: string | number | undefined
): string | undefined => {
if (typeof value === "string") {
return encodeURIComponent(value);
} else if (typeof value === "number") {
return value.toString(10);
} else {
return undefined;
}
};
class AnalyticsData {
public uuid: string;
public pathname: string;
public ua: string;
public referrer: string;
public dimensions: Dimensions;
public timezone: string | undefined;
public duration: number = 0;
public scroll: number = 0;
private start: number;
private hideStart: number = 0;
private totalHidden: number = 0;
constructor() {
this.uuid = uuidv4();
this.pathname = ensureStartSlash(window.location.pathname);
this.ua = navigator.userAgent;
this.referrer = cleanReferrer(document.referrer || "");
this.dimensions = getDimensions();
this.timezone = getTimeZone();
this.start = Date.now();
}
// Collect up the `AnalyticsParams` and render them into a querystring
toParams(): string {
var obj: AnalyticsParams = {
uuid: this.uuid,
path: this.pathname,
ua: this.ua,
viewport_width: this.dimensions.viewportWidth,
viewport_height: this.dimensions.viewportHeight,
screen_width: this.dimensions.screenWidth,
screen_height: this.dimensions.screenHeight,
referrer: this.referrer,
duration: this.duration,
scroll: this.scroll,
};
if (this.timezone) {
obj.timezone = this.timezone;
}
return (Object.keys(obj) as Array<keyof AnalyticsParams>)
.map((key) => {
const encoded = encodeParamValue(obj[key]);
return encoded ? `${key}=${encoded}` : undefined;
})
.filter((param) => typeof param === "string")
.join("&");
}
toBeaconJson(): string {
return JSON.stringify({
uuid: this.uuid,
path: this.pathname,
duration: this.duration,
scroll: this.scroll,
});
}
onScroll() {
const position = getPosition();
if (this.scroll < position) {
this.scroll = position;
}
}
onVisibilityChange() {
if (window.document.hidden) {
if (!("onpagehide" in window)) {
this.sendBeacon();
}
this.hideStart = Date.now();
} else {
this.totalHidden += Date.now() - this.hideStart;
}
}
sendBeacon() {
this.duration = Math.max(
0,
Math.min(
MAX_DURATION,
Math.round((Date.now() - this.start - this.totalHidden) / 1000.0)
)
);
this.scroll = Math.max(0, this.scroll, getPosition());
navigator.sendBeacon(ANALYTICS_APPEND_URL, this.toBeaconJson());
}
}
// The maximum duration (2 hours)
const MAX_DURATION = 2 * 60 * 60;
// This is the path to our analytics image. We append the query string from the `AnalyticsData` to this URL.
const ANALYTICS_URL = "https://pv.blakerain.com/pv.gif";
// This is the path to our analytics append function.
const ANALYTICS_APPEND_URL = "https://pv.blakerain.com/append";
// Renders an image using our analytics image.
//
// This effectively calls our analytics API and passes the data we collect in the `AnalyticsData` class.
const AnalyticsImage = ({ data }: { data: AnalyticsData }) => {
return (
<img
alt=""
style={{
visibility: "hidden",
width: 0,
height: 0,
position: "absolute",
bottom: 0,
right: 0,
}}
src={`${ANALYTICS_URL}?${data.toParams()}`}
/>
);
};
/**
* Analytics embed
*
* This component will add an invisible image element into the document. The URL of the image includes analytics
* information gathered in the `AnalyticsData` class. The loading of this image causes the analytics information to be
* stored in our database.
*/
const Analytics = () => {
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(
null
);
useEffect(() => {
if (typeof window !== "undefined") {
const hostname = window.location.hostname;
// Do not record analytics for localhost or an IP address
if (hostname === "localhost" || isIPAddressLike(hostname)) {
console.warn(`Ignoring analytics for hostname: ${hostname}`);
return;
}
const analytics = new AnalyticsData();
setAnalyticsData(analytics);
const onScrollEvent = () => {
analytics.onScroll();
};
const onVisibilityChange = () => {
analytics.onVisibilityChange();
};
const onPageHide = () => {
analytics.sendBeacon();
};
window.addEventListener("scroll", onScrollEvent);
window.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("pagehide", onPageHide);
return () => {
window.removeEventListener("scroll", onScrollEvent);
window.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("pagehide", onPageHide);
analytics.sendBeacon();
};
}
}, []);
// navigator.sendBeacon
return analyticsData ? <AnalyticsImage data={analyticsData} /> : null;
};
export const AnalyticsInformation = () => {
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(
null
);
useEffect(() => {
if (typeof window !== "undefined") {
setAnalyticsData(new AnalyticsData());
}
}, []);
if (analyticsData) {
return (
<table className="columnOriented">
<tbody>
<tr>
<th style={{ width: "20rem" }}>Pathname of page</th>
<td>{analyticsData.pathname}</td>
</tr>
<tr>
<th>User Agent</th>
<td>{analyticsData.ua}</td>
</tr>
<tr>
<th>Referrer</th>
<td>{analyticsData.referrer}</td>
</tr>
<tr>
<th>Screen Dimensions</th>
<td>
{analyticsData.dimensions.screenWidth} x{" "}
{analyticsData.dimensions.screenHeight}
</td>
</tr>
<tr>
<th>Viewport Dimensions</th>
<td>
{analyticsData.dimensions.viewportWidth} x{" "}
{analyticsData.dimensions.viewportHeight}
</td>
</tr>
<tr>
<th>Time zone</th>
<td>{analyticsData.timezone}</td>
</tr>
</tbody>
</table>
);
} else {
return null;
}
};
export default Analytics;

View File

@ -1,41 +0,0 @@
import React, { FC, useEffect, useRef } from "react";
/**
* Provides a dismissable wrapper around an element
*
* When a mouse click event is received outside of this component, the `onDismiss` property is called.
*/
export const Dismissable: FC<
React.PropsWithChildren<{
onDismiss: (event: MouseEvent) => void;
className?: string;
}>
> = ({ onDismiss, className, children }) => {
const container = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// Make sure that the click was not on or in our container before we call the `onDismiss` function.
if (
event.target &&
container.current &&
!container.current.contains(event.target as Node)
) {
onDismiss(event);
}
};
document.addEventListener("click", handleClickOutside, true);
return () => {
document.removeEventListener("click", handleClickOutside, true);
};
}, [onDismiss]);
return (
<div ref={container} className={className}>
{children}
</div>
);
};
export default Dismissable;

View File

@ -1,272 +0,0 @@
@import "../styles/colors.scss";
@import "../styles/tools.scss";
$color-footer-link: rgba(255, 255, 255, 0.7);
$color-footer-link-hover: rgba(255, 255, 255, 1);
$color-footer-link-separator: rgba(255, 255, 255, 0.75);
$color-footer-popup-background: lighten($primary-background, 10%);
$color-footer-popup-link: rgba(255, 255, 255, 1);
$color-footer-popup-link-hover-background: lighten($primary-background, 20%);
// The outer footer component, which provides the full-width primary background and some padding
.footer {
background-color: $primary-background;
padding-top: 20px;
padding-bottom: 20px;
}
// THe inner component of the footer, which consumes all available space up to a maximum width, corresponding to the
// main body size. This arranges for it's children to be spaced either side of the footer, which we override on smaller
// displays to arrange children vertically.
.footerInner {
// Use a slightly smaller font for the footer
font-size: 1.3rem;
// Arrange our children horizontally (a row) and space them equally.
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
// Any link found in the footer should be white, and become a tad brighter when we hover.
a {
color: $color-footer-link;
&:hover {
color: $color-footer-link-hover;
text-decoration: none;
}
}
}
// The copyright notice matches the color of our links
.copyright {
color: $color-footer-link;
}
// A navigation in the footer. This contains a series of links that are arranged horizontally, or vertically on mobile.
.navigation {
display: flex;
margin-bottom: 1rem;
// Ensure that links in the navigation have a dot between them. We do this by using a ':before' pseudo class that
// lets us add a small dot to the left of the link. Note that we don't apply this to the first child.
> a,
.popup {
position: relative;
margin-left: 20px;
&:not(:first-child):before {
content: "";
position: absolute;
display: block;
top: 12px;
left: -11px;
width: 2px;
height: 2px;
background-color: $color-footer-link-separator;
border-radius: 100%;
}
}
}
// Add a small triangle at the end of the link that points up. We override this on mobile devices to point in different
// directions, corresponding with the UI arrangement.
.popup {
> a:after {
content: "";
position: relative;
display: inline-block;
width: 0px;
height: 0px;
top: -3px;
left: 4px;
border-left: 0.5rem solid transparent;
border-right: 0.5rem solid transparent;
border-bottom: 0.5rem solid $color-footer-link;
}
}
// When the popup is open, set the menu display to block (previous was 'none')
.popup.popupOpen {
.popupMenu {
display: block;
}
}
// Arrange the popup menu to be an absolute positioned element aligned to the right of it's container.
.popupMenu {
position: absolute;
bottom: 3rem;
right: -1.5em;
width: 20rem;
z-index: 100;
display: none;
padding: 1rem 0;
background-color: $color-footer-popup-background;
border-radius: 5px;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
li {
margin: 0;
padding: 0;
display: block;
a {
display: block;
margin: 0;
color: $color-footer-popup-link;
padding: 0.5rem 1rem;
&:hover {
background-color: $color-footer-popup-link-hover-background;
}
}
}
}
}
@media (min-width: 700px) {
// On larger displays, add a small arrow to the bottom-right of the popup menu that should line up with the arrow on
// the right of the popup link.
.popupMenu {
&:after {
content: "";
display: block;
position: absolute;
right: 1rem;
bottom: -1rem;
width: 0;
height: 0;
border-left: 1rem solid transparent;
border-right: 1rem solid transparent;
border-top: 1rem solid $color-footer-popup-background;
}
}
}
@media (max-width: 650px) {
.inner {
flex-direction: column;
align-items: stretch;
}
.copyright {
margin-bottom: 1rem;
color: darken($color-footer-link, 20%);
> a {
color: darken($color-footer-link, 20%);
}
}
.navigation {
flex-direction: column;
align-items: stretch;
margin-bottom: 0;
> a,
.popup {
margin-left: 0;
padding: 1rem 0;
&:not(:first-child):before {
display: none;
}
}
> .popup {
// The pop-up should arrange it's children in a column
display: flex;
flex-direction: column;
align-items: stretch;
// Adjust a pop-up menu so that it's full width (overlapping the container padding)
padding: 1rem 2rem;
margin-left: -2rem;
margin-right: -2rem;
// When the popup is open on smaller displays, we hide the padding at the bottom and match the background of the
// popup menu.
&.popupOpen {
padding-bottom: 0;
background-color: $color-footer-popup-background;
}
// We re-arrange the flex order of the children so the menu opens _below_ the link, rather than above it.
> div {
order: 1;
}
> a {
order: 0;
}
}
}
// Change the arrows in the popup menu link to a different orientation for smaller devices
.popup {
> a {
// Make the arrow after the link of a footer popup menu point to the right on smaller devices
&:after {
top: 0px;
left: 6px;
border-top: 0.5rem solid transparent;
border-bottom: 0.5rem solid transparent;
border-left: 0.5rem solid $color-footer-link;
}
}
&.popupOpen {
// Make the arrow after the link of a footer popup menu point down on smaller devices
> a:after {
top: 4px;
border-left: 0.5rem solid transparent;
border-right: 0.5rem solid transparent;
border-top: 0.5rem solid $color-footer-link;
}
}
}
// Show the pop-up menu as a normal flex child rather than a floating element.
.popupMenu {
position: relative;
bottom: initial;
right: initial;
width: auto;
// Don't have any shadow, background or border radius
box-shadow: none;
border-radius: 0;
background: none;
// Make sure that the links in the popup menu have an increased gap for mobile
ul > li > a {
padding: 1rem 0;
}
}
}
@media print {
.footer {
display: none;
}
}

View File

@ -1,74 +0,0 @@
import React, { FC, useState } from "react";
import cn from "classnames";
import Link from "next/link";
import styles from "./Footer.module.scss";
import Dismissable from "./Dismissable";
/**
* A drop-down (or drop-up) menu in the footer
*
* This component encapsulates the drop-down footer menus
*/
const FooterDropdown: FC<React.PropsWithChildren<{ title: string }>> = ({
title,
children,
}) => {
const [visible, setVisible] = useState(false);
const onLinkClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
event.preventDefault();
setVisible(!visible);
};
return (
<Dismissable
onDismiss={() => setVisible(false)}
className={cn(styles.popup, visible && styles.popupOpen)}
>
<div className={cn(styles.popupMenu)}>
<ul>{children}</ul>
</div>
<a href="#" onClick={onLinkClick}>
{title}
</a>
</Dismissable>
);
};
/**
* Provides the footer for the website.
*
* The site footer includes a copyright notice and a set of links. Some of those links may be drop-down menus that
* provide additional links.
*/
export const Footer: FC = () => {
const date = new Date();
return (
<footer className={cn(styles.footer, styles.outer)}>
<div className={cn(styles.footerInner, styles.inner)}>
<section className={styles.copyright}>
<Link href="/">Blake Rain</Link> &copy;{" "}
{date.getFullYear().toString()}
</section>
<nav className={styles.navigation}>
<Link href="/blog">Latest Posts</Link>
<Link href="/tags">Tags</Link>
<Link href="/disclaimer">Disclaimer</Link>
<a rel="noreferrer" href="https://github.com/BlakeRain">
GitHub
</a>
<a rel="noreferrer" href="https://mastodonapp.uk/@BlakeRain">
Mastodon
</a>
<FooterDropdown title="Tools">
<li>
<Link href="/analytics">Analytics Dashboard</Link>
<Link href="/tools/position-size">Position Size Calculator</Link>
</li>
</FooterDropdown>
</nav>
</div>
</footer>
);
};

View File

@ -1,12 +0,0 @@
@import "../styles/tools.scss";
// .content {
// display: flex;
// justify-content: center;
// padding: 0 5vw;
// }
//
// .inner {
// width: 100%;
// max-width: 1040px;
// }

View File

@ -1,66 +0,0 @@
import Head from "next/head";
import React, { FC } from "react";
import { SiteNavigation } from "../lib/navigation";
import { Footer } from "./Footer";
import styles from "./Layout.module.scss";
import { Navigation } from "./Navigation";
export interface LayoutProps {
navigation: SiteNavigation[];
wrap?: boolean;
}
export const Layout: FC<React.PropsWithChildren<LayoutProps>> = ({
navigation,
children,
wrap,
}) => {
return (
<React.Fragment>
<Head>
<meta name="referer" content="no-referrer-when-downgrade" />
<link
rel="apple-touch-icon"
sizes="76x76"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#12304c" />
<meta name="msapplication-TileColor" content="#12304e" />
<meta name="theme-color" content="#12304e" />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "Blake Rain",
url: "https://blakerain.com",
}),
}}
/>
</Head>
<Navigation navigation={navigation} />
{wrap ? (
<div className={styles.outer}>
<div className={styles.inner}>{children}</div>
</div>
) : (
children
)}
<Footer />
</React.Fragment>
);
};

View File

@ -1,150 +0,0 @@
@import "../styles/colors.scss";
.navigation {
background-color: $primary-background;
display: flex;
flex-direction: row;
justify-content: center;
padding: 0 5vw;
}
.inner {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
max-width: 1040px;
width: 100%;
margin: 9px 0;
font-size: 1.3rem;
}
.left,
.right {
display: flex;
flex-direction: row;
}
.right {
ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
a {
display: block;
padding: 11px 10px 3px 10px;
svg {
height: 1.8rem;
fill: white;
opacity: 0.8;
}
&:hover {
svg {
opacity: 1;
}
}
}
}
}
.logo {
padding: 6px 12px 0 0;
img {
display: block;
width: auto;
height: 28px;
}
}
.siteNavigation {
margin: 0;
padding: 0;
list-style: none;
display: flex;
li {
display: block;
a {
display: block;
position: relative;
padding: 12px 12px 0px 12px;
color: white;
opacity: 0.8;
text-transform: uppercase;
&.active {
opacity: 1;
}
&:before {
content: "";
position: absolute;
left: 12px;
right: 12px;
bottom: 8px;
height: 1px;
background-color: white;
opacity: 0;
}
&:hover {
opacity: 1;
&:before {
opacity: 0.75;
}
}
}
}
}
.highlightControls {
background-color: $primary-background;
color: $color-light-grey;
display: flex;
flex-direction: row;
justify-content: center;
padding: 1rem 5vw;
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 100;
div {
padding: 1rem;
line-height: 1.15;
}
div + button,
button + button {
margin-left: 1rem;
}
}
@media (max-width: 750px) {
.right {
display: none;
}
}
@media print {
.navigation {
display: none;
}
}

View File

@ -1,139 +0,0 @@
import React, { FC } from "react";
import Link from "next/link";
import { SiteNavigation } from "../lib/navigation";
import styles from "./Navigation.module.scss";
import Search from "./icons/Search";
import GitHub from "./icons/GitHub";
import DevTo from "./icons/DevTo";
import Rss from "./icons/Rss";
import Mastodon from "./icons/Mastodon";
const trimTrailingSlash = (str: string): string => {
return str.length > 0 && str.endsWith("/")
? str.substring(0, str.length - 1)
: str;
};
const SiteNavLinks: FC<{ navigation: SiteNavigation[] }> = ({ navigation }) => {
return (
<ul className={styles.siteNavigation}>
{navigation.map((item, index) => {
return (
<li key={index.toString()}>
<Link href={trimTrailingSlash(item.url)}>{item.label}</Link>
</li>
);
})}
</ul>
);
};
const SiteNav: FC<{ navigation: SiteNavigation[] }> = ({ navigation }) => {
return (
<React.Fragment>
<Link href="/" className={styles.logo}>
<img
src="/media/logo-text.png"
width={154}
height={28}
alt="Blake Rain"
/>
</Link>
<SiteNavLinks navigation={navigation} />
</React.Fragment>
);
};
const SearchLink: FC = () => {
return (
<Link href="/search">
<Search />
</Link>
);
};
const GitHubLink: FC = () => {
return (
<a
href="https://github.com/BlakeRain"
title="GitHub"
target="_blank"
rel="noreferrer"
>
<GitHub />
</a>
);
};
const MastodonLink: FC = () => {
return (
<a
href="https://mastodonapp.uk/@BlakeRain"
title="@BlakeRain@mastodonapp.uk"
target="_blank"
rel="me noreferrer"
>
<Mastodon />
</a>
);
};
const DevLink: FC = () => {
return (
<a
href="https://dev.to/blakerain"
title="blakerain"
target="_blank"
rel="noreferrer"
>
<DevTo />
</a>
);
};
const RssLink: FC = () => {
return (
<a href="/feeds/feed.xml" title="RSS feed">
<Rss />
</a>
);
};
const NavigationBar: FC<{ navigation: SiteNavigation[] }> = (props) => {
return (
<nav className={styles.navigation}>
<div className={styles.inner}>
<div className={styles.left}>
<SiteNav navigation={props.navigation} />
</div>
<div className={styles.right}>
<ul>
<li>
<SearchLink />
</li>
<li>
<GitHubLink />
</li>
<li>
<MastodonLink />
</li>
<li>
<DevLink />
</li>
<li>
<RssLink />
</li>
</ul>
</div>
</div>
</nav>
);
};
export const Navigation: FC<{ navigation: SiteNavigation[] }> = (props) => {
return (
<React.Fragment>
<NavigationBar {...props} />
</React.Fragment>
);
};

View File

@ -1,91 +0,0 @@
@import "../styles/colors.scss";
@import "../styles/fonts.scss";
.postCard {
display: flex;
flex-direction: column;
min-height: 220px;
}
.postCardCoverImage {
display: block;
width: 100%;
height: 200px;
position: relative;
img {
border-radius: 8px;
object-fit: cover;
}
}
.postCardInner {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
a {
display: block;
margin-bottom: 1em;
}
header {
font-size: 2.2rem;
line-height: 1.15em;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-top: 15px;
margin-bottom: 10px;
}
section {
color: $color-mid-grey;
font-family: $text-font-family;
font-size: 2rem;
line-height: 1.6em;
}
}
@media (min-width: 1070px) {
.postCardLarge {
grid-column-start: 1;
grid-column-end: 4;
flex-direction: row;
.postCardCoverImage {
width: 320px;
height: 220px;
flex-grow: 0;
flex-shrink: 0;
}
.postCardInner {
margin-left: 40px;
justify-content: start;
}
}
}
@media (prefers-color-scheme: dark) {
.postCardInner {
header {
color: rgba(255, 255, 255, 0.85);
}
section {
color: lighten($color-mid-grey, 30%);
}
}
}
.postCards {
margin: 40px 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-row-gap: 80px;
grid-column-gap: 40px;
}

View File

@ -1,67 +0,0 @@
import { FC } from "react";
import cn from "classnames";
import Link from "next/link";
import { Tag, Tags } from "../lib/tags";
import { PostInfo } from "../lib/content";
import { PostDetails } from "./PostDetails";
import { TagList } from "./display/TagList";
import Image from "./display/Image";
import styles from "./PostCard.module.scss";
export const PostCard: FC<{
post: PostInfo;
large: boolean;
tags: Tag[];
}> = ({ post, large, tags }) => {
return (
<article className={cn(styles.postCard, { [styles.postCardLarge]: large })}>
{post.coverImage ? (
<Link href={"/blog/" + post.slug} className={styles.postCardCoverImage}>
<Image
src={post.coverImage}
alt={post.title}
fill
priority={true}
sizes="(max-width: 1200px) 100vw, 33vw"
/>
</Link>
) : null}
<div className={styles.postCardInner}>
<Link href={"/blog/" + post.slug}>
<header>{post.title}</header>
{post.excerpt ? <section>{post.excerpt}</section> : null}
</Link>
<PostDetails doc={post}>
<TagList tags={tags} />
</PostDetails>
</div>
</article>
);
};
export const PostCards: FC<{
tags: Tags;
posts: PostInfo[];
feature?: boolean;
}> = ({ tags, posts, feature }) => {
return (
<div className={styles.postCards}>
{posts.map((post, index) => (
<PostCard
key={index.toString()}
post={post}
large={Boolean(feature) && index === 0 && posts.length > 2}
tags={post.tags.reduce((acc, tag_slug) => {
const tag = tags.get(tag_slug);
if (tag) {
acc.push(tag);
}
return acc;
}, [] as Tag[])}
/>
))}
</div>
);
};

View File

@ -1,96 +0,0 @@
@import "../styles/colors.scss";
.postDetails {
display: flex;
flex-direction: row;
}
.postDetailsInner {
margin-left: 6px;
text-transform: uppercase;
font-size: 1.2rem;
line-height: 1.4em;
font-weight: 400;
color: $color-dark-grey;
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
li {
position: relative;
display: block;
margin-right: 10px;
font-weight: 600;
font-size: 1.3rem;
&:after {
content: "";
display: block;
position: absolute;
top: 7px;
right: -6px;
width: 2px;
height: 2px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 100%;
}
&:last-of-type {
margin-left: 0;
&:after {
display: none;
}
}
a {
margin-bottom: 0;
}
}
}
}
@media (prefers-color-scheme: dark) {
.postDetailsInner {
color: $color-light-grey;
}
}
.authorImage {
width: 34px;
height: 34px;
border-radius: 100%;
}
.dateAndTime {
display: flex;
flex-direction: row;
}
.readingTime {
margin-left: 20px;
position: relative;
&:before {
content: "";
display: block;
position: absolute;
top: 7px;
left: -11px;
width: 2px;
height: 2px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 100%;
}
}

View File

@ -1,35 +0,0 @@
import React, { FC } from "react";
import { DocInfo } from "../lib/content";
import { DateSpan } from "./display/DateSpan";
import styles from "./PostDetails.module.scss";
export const PostDetails: FC<
React.PropsWithChildren<{ doc: DocInfo & { readingTime?: number } }>
> = ({ doc, children }) => {
return (
<div className={styles.postDetails}>
<div>
<img
className={styles.authorImage}
src="/media/profile.png"
alt="Blake Rain profile picture"
/>
</div>
<div className={styles.postDetailsInner}>
<ul>
<li>Blake Rain</li>
</ul>
<div className={styles.dateAndTime}>
<DateSpan date={doc.published || "1970-01-01T00:00:00.000Z"} />
{typeof doc.readingTime === "number" && (
<span className={styles.readingTime}>
{doc.readingTime} min read
</span>
)}
</div>
{children}
</div>
</div>
);
};

View File

@ -1,37 +0,0 @@
import React, { FC } from "react";
const BrowserIcon: FC<{ name: string }> = ({ name }) => {
if (name.includes("Chrome")) {
return (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Google Chrome</title>
<path d="M12 0C8.21 0 4.831 1.757 2.632 4.501l3.953 6.848A5.454 5.454 0 0 1 12 6.545h10.691A12 12 0 0 0 12 0zM1.931 5.47A11.943 11.943 0 0 0 0 12c0 6.012 4.42 10.991 10.189 11.864l3.953-6.847a5.45 5.45 0 0 1-6.865-2.29zm13.342 2.166a5.446 5.446 0 0 1 1.45 7.09l.002.001h-.002l-5.344 9.257c.206.01.413.016.621.016 6.627 0 12-5.373 12-12 0-1.54-.29-3.011-.818-4.364zM12 16.364a4.364 4.364 0 1 1 0-8.728 4.364 4.364 0 0 1 0 8.728Z" />
</svg>
);
} else if (name.includes("Safari")) {
return (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Safari</title>
<path d="M12 24C5.373 24 0 18.627 0 12S5.373 0 12 0s12 5.373 12 12-5.373 12-12 12zm0-.75c6.213 0 11.25-5.037 11.25-11.25S18.213.75 12 .75.75 5.787.75 12 5.787 23.25 12 23.25zM12 2a.25.25 0 0 1 .25.25v1a.25.25 0 1 1-.5 0v-1A.25.25 0 0 1 12 2zm0 18.5a.25.25 0 0 1 .25.25v1a.25.25 0 1 1-.5 0v-1a.25.25 0 0 1 .25-.25zm7.071-15.571a.25.25 0 0 1 0 .353l-.707.708a.25.25 0 0 1-.354-.354l.708-.707a.25.25 0 0 1 .353 0zM5.99 18.01a.25.25 0 0 1 0 .354l-.708.707a.25.25 0 1 1-.353-.353l.707-.708a.25.25 0 0 1 .354 0zM4.929 4.93a.25.25 0 0 1 .353 0l.708.707a.25.25 0 0 1-.354.354l-.707-.708a.25.25 0 0 1 0-.353zM18.01 18.01a.25.25 0 0 1 .354 0l.707.708a.25.25 0 1 1-.353.353l-.708-.707a.25.25 0 0 1 0-.354zM2 12a.25.25 0 0 1 .25-.25h1a.25.25 0 1 1 0 .5h-1A.25.25 0 0 1 2 12zm18.5 0a.25.25 0 0 1 .25-.25h1a.25.25 0 1 1 0 .5h-1a.25.25 0 0 1-.25-.25zm-4.593-9.205a.25.25 0 0 1 .133.328l-.391.92a.25.25 0 1 1-.46-.195l.39-.92a.25.25 0 0 1 .328-.133zM8.68 19.825a.25.25 0 0 1 .132.327l-.39.92a.25.25 0 0 1-.46-.195l.39-.92a.25.25 0 0 1 .328-.133zM21.272 8.253a.25.25 0 0 1-.138.325l-.927.375a.25.25 0 1 1-.188-.464l.927-.374a.25.25 0 0 1 .326.138zm-17.153 6.93a.25.25 0 0 1-.138.326l-.927.374a.25.25 0 1 1-.188-.463l.927-.375a.25.25 0 0 1 .326.138zM8.254 2.728a.25.25 0 0 1 .325.138l.375.927a.25.25 0 0 1-.464.188l-.374-.927a.25.25 0 0 1 .138-.326zm6.93 17.153a.25.25 0 0 1 .326.138l.374.927a.25.25 0 1 1-.463.188l-.375-.927a.25.25 0 0 1 .138-.326zM2.795 8.093a.25.25 0 0 1 .328-.133l.92.391a.25.25 0 0 1-.195.46l-.92-.39a.25.25 0 0 1-.133-.328zm17.03 7.228a.25.25 0 0 1 .327-.132l.92.39a.25.25 0 1 1-.195.46l-.92-.39a.25.25 0 0 1-.133-.328zM12.879 12.879L11.12 11.12l-4.141 5.9 5.899-4.142zm6.192-7.95l-5.834 8.308-8.308 5.834 5.834-8.308 8.308-5.834z" />
</svg>
);
} else if (name.includes("Firefox")) {
return (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Firefox Browser</title>
<path d="M8.824 7.287c.008 0 .004 0 0 0zm-2.8-1.4c.006 0 .003 0 0 0zm16.754 2.161c-.505-1.215-1.53-2.528-2.333-2.943.654 1.283 1.033 2.57 1.177 3.53l.002.02c-1.314-3.278-3.544-4.6-5.366-7.477-.091-.147-.184-.292-.273-.446a3.545 3.545 0 01-.13-.24 2.118 2.118 0 01-.172-.46.03.03 0 00-.027-.03.038.038 0 00-.021 0l-.006.001a.037.037 0 00-.01.005L15.624 0c-2.585 1.515-3.657 4.168-3.932 5.856a6.197 6.197 0 00-2.305.587.297.297 0 00-.147.37c.057.162.24.24.396.17a5.622 5.622 0 012.008-.523l.067-.005a5.847 5.847 0 011.957.222l.095.03a5.816 5.816 0 01.616.228c.08.036.16.073.238.112l.107.055a5.835 5.835 0 01.368.211 5.953 5.953 0 012.034 2.104c-.62-.437-1.733-.868-2.803-.681 4.183 2.09 3.06 9.292-2.737 9.02a5.164 5.164 0 01-1.513-.292 4.42 4.42 0 01-.538-.232c-1.42-.735-2.593-2.121-2.74-3.806 0 0 .537-2 3.845-2 .357 0 1.38-.998 1.398-1.287-.005-.095-2.029-.9-2.817-1.677-.422-.416-.622-.616-.8-.767a3.47 3.47 0 00-.301-.227 5.388 5.388 0 01-.032-2.842c-1.195.544-2.124 1.403-2.8 2.163h-.006c-.46-.584-.428-2.51-.402-2.913-.006-.025-.343.176-.389.206-.406.29-.787.616-1.136.974-.397.403-.76.839-1.085 1.303a9.816 9.816 0 00-1.562 3.52c-.003.013-.11.487-.19 1.073-.013.09-.026.181-.037.272a7.8 7.8 0 00-.069.667l-.002.034-.023.387-.001.06C.386 18.795 5.593 24 12.016 24c5.752 0 10.527-4.176 11.463-9.661.02-.149.035-.298.052-.448.232-1.994-.025-4.09-.753-5.844z" />
</svg>
);
} else if (name.includes("Edge")) {
return (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Microsoft Edge</title>
<path d="M21.86 17.86q.14 0 .25.12.1.13.1.25t-.11.33l-.32.46-.43.53-.44.5q-.21.25-.38.42l-.22.23q-.58.53-1.34 1.04-.76.51-1.6.91-.86.4-1.74.64t-1.67.24q-.9 0-1.69-.28-.8-.28-1.48-.78-.68-.5-1.22-1.17-.53-.66-.92-1.44-.38-.77-.58-1.6-.2-.83-.2-1.67 0-1 .32-1.96.33-.97.87-1.8.14.95.55 1.77.41.82 1.02 1.5.6.68 1.38 1.21.78.54 1.64.9.86.36 1.77.56.92.2 1.8.2 1.12 0 2.18-.24 1.06-.23 2.06-.72l.2-.1.2-.05zm-15.5-1.27q0 1.1.27 2.15.27 1.06.78 2.03.51.96 1.24 1.77.74.82 1.66 1.4-1.47-.2-2.8-.74-1.33-.55-2.48-1.37-1.15-.83-2.08-1.9-.92-1.07-1.58-2.33T.36 14.94Q0 13.54 0 12.06q0-.81.32-1.49.31-.68.83-1.23.53-.55 1.2-.96.66-.4 1.35-.66.74-.27 1.5-.39.78-.12 1.55-.12.7 0 1.42.1.72.12 1.4.35.68.23 1.32.57.63.35 1.16.83-.35 0-.7.07-.33.07-.65.23v-.02q-.63.28-1.2.74-.57.46-1.05 1.04-.48.58-.87 1.26-.38.67-.65 1.39-.27.71-.42 1.44-.15.72-.15 1.38zM11.96.06q1.7 0 3.33.39 1.63.38 3.07 1.15 1.43.77 2.62 1.93 1.18 1.16 1.98 2.7.49.94.76 1.96.28 1 .28 2.08 0 .89-.23 1.7-.24.8-.69 1.48-.45.68-1.1 1.22-.64.53-1.45.88-.54.24-1.11.36-.58.13-1.16.13-.42 0-.97-.03-.54-.03-1.1-.12-.55-.1-1.05-.28-.5-.19-.84-.5-.12-.09-.23-.24-.1-.16-.1-.33 0-.15.16-.35.16-.2.35-.5.2-.28.36-.68.16-.4.16-.95 0-1.06-.4-1.96-.4-.91-1.06-1.64-.66-.74-1.52-1.28-.86-.55-1.79-.89-.84-.3-1.72-.44-.87-.14-1.76-.14-1.55 0-3.06.45T.94 7.55q.71-1.74 1.81-3.13 1.1-1.38 2.52-2.35Q6.68 1.1 8.37.58q1.7-.52 3.58-.52Z" />
</svg>
);
} else {
return null;
}
};
export default BrowserIcon;

View File

@ -1,164 +0,0 @@
import React, { FC, useMemo } from "react";
import cn from "classnames";
import { BrowserData } from "../../lib/analytics";
import reportStyles from "./Report.module.scss";
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { formatNumber } from "../../lib/utils";
const OTHER_COLORS = ["#6588b7", "#88a2bc", "#f0dbb0", "#efb680", "#d99477"];
const BROWSER_COLORS: { [key: string]: string } = {
Safari: "#4594b5",
Chrome: "#FFA055",
Firefox: "#C9472F",
};
function browserColor(name: string, index: number): string {
const color = BROWSER_COLORS[name];
if (color) {
return color;
}
return OTHER_COLORS[index % OTHER_COLORS.length];
}
type NamedData = { [name: string]: any };
interface CombinedData extends NamedData {
label: string;
}
const BrowserTooltip = ({
year,
param,
names,
formatDay,
active,
payload,
}: {
year: number;
param: number;
names: string[];
formatDay: (year: number, param: number, category: string) => string;
active?: boolean;
payload?: any;
}) => {
if (active && payload && payload.length > 0) {
const data = payload[0].payload as CombinedData;
const total = names.reduce((total, name) => total + data[name], 0);
const rows: any[] = [];
names.forEach((name, index) => {
if (data[name] > 0) {
rows.unshift(
<tr key={index.toString()}>
<th
style={{
color: "#000000",
backgroundColor: browserColor(name, index),
}}
>
{name.replace("-", " ")}
</th>
<td>{formatNumber(data[name], 0)}</td>
<td>
{formatNumber((100 * data[name]) / total, 0, undefined, "%")}
</td>
</tr>
);
}
});
return (
<div className={cn(reportStyles.tooltip, reportStyles.large)}>
<p className={reportStyles.title}>
{formatDay(year, param, data.label)} - {formatNumber(total, 0)} views
</p>
<table>
<tbody>{rows}</tbody>
</table>
{names.length === 0 && (
<div className={reportStyles.notice}>No Data</div>
)}
</div>
);
} else {
return <div className={reportStyles.tooltip}></div>;
}
};
export const BrowserReport: FC<{
year: number;
param: number;
formatDay: (year: number, param: number, category: string) => string;
browserData: BrowserData;
startOffset: number;
labelMapper: (day: number) => string;
}> = ({ year, param, formatDay, browserData, startOffset, labelMapper }) => {
const [browsers, names] = useMemo(() => {
let combined: CombinedData[] = [];
let names: string[] = Object.keys(browserData);
let totals: { [key: string]: number } = {};
Object.keys(browserData).forEach((name) => {
let data = browserData[name];
let total = 0;
for (let item of data) {
let index = item.day - startOffset;
while (index >= combined.length) {
combined.push({
label: labelMapper(startOffset + combined.length),
});
}
combined[index][name] = item.count || 0;
total += item.count || 0;
}
totals[name] = total;
});
names.sort((a, b) => totals[a] - totals[b]);
return [combined, names];
}, [browserData, labelMapper, startOffset]);
return (
<>
<AreaChart width={1000} height={400} data={browsers}>
{names.map((name, index) => (
<Area
key={index.toString()}
type="monotone"
dataKey={name}
stackId="1"
stroke={browserColor(name, index)}
fill={browserColor(name, index)}
strokeWidth={2}
/>
))}
<CartesianGrid stroke="#ccc" strokeDasharray="5 5" />
<XAxis dataKey="label" />
<YAxis />
<Tooltip
content={
<BrowserTooltip
year={year}
param={param}
names={names}
formatDay={formatDay}
/>
}
/>
</AreaChart>
</>
);
};

View File

@ -1,48 +0,0 @@
import React, { FC } from "react";
import {
getMonthViews,
getBrowsersMonth,
getMonthPageCount,
} from "../../lib/analytics";
import { Report } from "./Report";
const MonthlyReport: FC<{ paths: string[]; token: string }> = ({
paths,
token,
}) => {
return (
<Report
paths={paths}
paramInfo={{
min: 0,
max: 11,
startOffset: 1,
labelMapper: (day) => day.toString(),
fromDate: (date) => date.getMonth(),
format: (year, month) =>
`${(1 + month).toString().padStart(2, "0")}/${year
.toString()
.padStart(4, "0")}`,
formatDay: (year, month, day) =>
`${day.toString().padStart(2, "0")}/${(1 + month)
.toString()
.padStart(2, "0")}/${year.toString().padStart(4, "0")}`,
}}
getData={async (path, year, month) => {
const data = (await getMonthViews(token, path, year, month)).map(
(item) => ({
category: item.day.toString(),
views: item.count || 0,
scroll: item.scroll || 0,
duration: item.duration || 0,
})
);
const browsers = (await getBrowsersMonth(token, year, month)).browsers;
const pages = await getMonthPageCount(token, year, month);
return { data, browsers, pages };
}}
/>
);
};
export default MonthlyReport;

View File

@ -1,222 +0,0 @@
@import "../../styles/colors.scss";
.reportContainer {
margin: 2em 0;
}
.reportToolbar {
display: flex;
flex-direction: row;
justify-content: space-between;
background-color: $primary-background;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
.left,
.right {
display: flex;
flex-direction: row;
}
.left {
padding-left: 10px;
padding-top: 8px;
button + button {
margin-left: 0.25em;
}
}
.right {
padding-top: 4px;
padding-right: 5px;
padding-bottom: 4px;
}
}
.reportTabButton {
display: inline-block;
border: none;
}
.activeTabButton {
background-color: $button-active-background;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.reportContents {
display: flex;
flex-direction: column;
border-color: $primary-background;
border-width: 1px;
border-style: none solid solid solid;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.reportControls {
display: flex;
flex-direction: row;
background-color: $color-light-grey;
padding: 1rem;
span {
display: block;
line-height: 1.15;
padding: 1rem 2rem;
color: rgba(255, 255, 255, 0.75);
background-color: $button-background;
}
span {
border-radius: 5px;
}
span + div,
div + span,
div + div,
span + span {
margin-left: 1rem;
}
select {
max-width: 30rem;
}
}
.reportCharts {
padding: 2rem 0;
> div + div {
margin-top: 2rem;
}
.reportChartsRow {
display: flex;
flex-direction: row;
align-items: flex-start;
}
table {
border-collapse: collapse;
min-width: 50%;
max-width: 100%;
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.15);
th,
td {
padding: 0.25rem 1rem;
}
thead {
tr {
background-color: $primary-background;
color: white;
text-align: left;
}
}
tbody {
tr {
&:not(:last-child) {
border-bottom: 1px solid $color-light-grey;
}
&:nth-of-type(even) {
background-color: rgba(0, 0, 0, 0.1);
}
&:last-child {
border-bottom-color: $color-mid-grey;
}
}
}
}
}
.tooltip {
font-size: 80%;
width: 22rem;
background-color: $color-white-grey;
.title {
margin: 0;
font-weight: bold;
text-align: center;
background-color: $color-light-grey;
}
.notice {
text-align: center;
font-style: italic;
}
table {
border: none;
width: 100%;
tbody tr {
th,
td {
width: 50%;
padding: 0.25rem 1rem;
}
th {
text-align: left;
background-color: $color-light-grey;
}
td {
text-align: right;
}
}
}
&.large {
width: 30rem;
}
}
@media (prefers-color-scheme: dark) {
.reportControls {
background-color: $color-mid-grey;
}
.tooltip {
background-color: $color-dark-grey;
.title,
table tbody tr th {
background-color: $color-mid-grey;
}
}
.reportCharts {
table {
tbody {
tr {
&:not(:last-child) {
border-bottom: 1px solid $color-mid-grey;
}
&:nth-of-type(even) {
background-color: rgba(255, 255, 255, 0.05);
}
&:last-child {
border-bottom-color: $color-dark-grey;
}
}
}
}
}
}

View File

@ -1,331 +0,0 @@
import React, { FC, useEffect, useState } from "react";
import styles from "./Report.module.scss";
import {
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { BrowserData, PageCount } from "../../lib/analytics";
import { BrowserReport } from "./BrowserReport";
import { formatNumber } from "../../lib/utils";
const PIE_COLORS = [
"#003f5c",
"#2f4b7c",
"#665191",
"#a05195",
"#d45087",
"#f95d6a",
"#ff7c43",
"#ffa600",
];
export interface ReportView {
category: string;
views: number;
scroll: number;
duration: number;
}
export interface ParamInfo {
min: number;
max: number;
startOffset: number;
labelMapper: (day: number) => string;
fromDate: (date: Date) => number;
format: (year: number, param: number) => string;
formatDay: (year: number, param: number, category: string) => string;
}
export interface ReportProps {
paths: string[];
paramInfo: ParamInfo;
getData: (
path: string,
year: number,
param: number
) => Promise<{
data: ReportView[];
browsers: BrowserData;
pages: PageCount[];
}>;
}
const ReportTooltip = ({
year,
param,
active,
payload,
paramInfo,
}: {
year: number;
param: number;
active?: boolean;
payload?: any;
paramInfo: ParamInfo;
}) => {
if (active && payload && payload.length > 0) {
const view = payload[0].payload as ReportView;
return (
<div className={styles.tooltip}>
<p className={styles.title}>
{paramInfo.formatDay(year, param, view.category)}
</p>
<table>
<tbody>
<tr>
<th>View Count</th>
<td>{formatNumber(view.views, 0)}</td>
</tr>
<tr>
<th>Avg. Scroll</th>
<td>
{view.views > 0
? formatNumber(view.scroll / view.views, 0, undefined, "%")
: "-"}
</td>
</tr>
<tr>
<th>Avg. Duration</th>
<td>
{view.views > 0
? formatNumber(
view.duration / view.views,
0,
undefined,
" secs"
)
: "-"}
</td>
</tr>
</tbody>
</table>
</div>
);
} else {
return (
<div className={styles.tooltip}>
<div className={styles.notice}>No Data</div>
</div>
);
}
};
const ReportPageTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: any;
}) => {
if (active && payload && payload.length > 0) {
const page = payload[0].payload as PageCount;
return (
<div className={styles.tooltip}>
<p className={styles.title}>{page.page}</p>
<table>
<tbody>
<tr>
<th>View Count</th>
<td>{formatNumber(page.count, 0)}</td>
</tr>
</tbody>
</table>
</div>
);
} else {
return (
<div className={styles.tooltip}>
<div className={styles.notice}>No Data</div>
</div>
);
}
};
export const Report: FC<ReportProps> = ({ paths, paramInfo, getData }) => {
const now = new Date();
const [path, setPath] = useState("site");
const [year, setYear] = useState(now.getFullYear());
const [param, setParam] = useState(paramInfo.fromDate(now));
const [views, setViews] = useState(0);
const [duration, setDuration] = useState(0);
const [scroll, setScroll] = useState(0);
const [data, setData] = useState<ReportView[]>([]);
const [browsers, setBrowsers] = useState<BrowserData>({});
const [pages, setPages] = useState<PageCount[]>([]);
useEffect(() => {
getData(path, year, param).then(({ data, browsers, pages }) => {
let total_views = 0;
let counted_views = 0;
let total_scroll = 0;
let total_duration = 0;
data.forEach((item) => {
if (item.scroll > 0 && item.duration > 0) {
total_scroll += item.scroll;
total_duration += item.duration;
counted_views += item.views;
}
total_views += item.views;
});
setBrowsers(browsers);
setData(data);
setViews(total_views);
pages.sort((a, b) => b.count - a.count);
setPages(pages);
if (counted_views > 0) {
setScroll(total_scroll / counted_views);
setDuration(total_duration / counted_views);
} else {
setScroll(0);
setDuration(0);
}
});
}, [getData, path, year, param]);
const handlePrevClick = () => {
if (param === paramInfo.min) {
setYear(year - 1);
setParam(paramInfo.max);
} else {
setParam(param - 1);
}
};
const handleNextClick = () => {
if (param === paramInfo.max) {
setYear(year + 1);
setParam(paramInfo.min);
} else {
setParam(param + 1);
}
};
const handlePathChange: React.ChangeEventHandler<HTMLSelectElement> = (
event
) => {
setPath(event.target.value);
};
return (
<div className={styles.reportContents}>
<div className={styles.reportControls}>
<span>
<b>Date:</b> {paramInfo.format(year, param)}
</span>
<div className="buttonGroup">
<button type="button" onClick={handlePrevClick}>
&larr;
</button>
<button type="button" onClick={handleNextClick}>
&rarr;
</button>
</div>
<div>
<select value={path} onChange={handlePathChange}>
{paths.map((path, index) => (
<option key={index.toString()} value={path}>
{path}
</option>
))}
</select>
</div>
</div>
{data.length > 0 && (
<div className={styles.reportControls}>
<span>
<b>Total:</b> {views}
</span>
{scroll > 0 && (
<span>
<b>Avg. Scroll:</b> {scroll.toFixed(2)}%
</span>
)}
{duration > 0 && (
<span>
<b>Avg. Duration:</b> {duration.toFixed(2)} seconds
</span>
)}
</div>
)}
<div className={styles.reportCharts}>
{data.length > 0 && (
<LineChart width={1000} height={400} data={data}>
<Line
type="monotone"
dataKey="views"
stroke="#0074d9"
strokeWidth={2}
/>
<CartesianGrid stroke="#ccc" strokeDasharray="5 5" />
<XAxis dataKey="category" />
<YAxis />
<Tooltip
content={
<ReportTooltip
year={year}
param={param}
paramInfo={paramInfo}
/>
}
/>
</LineChart>
)}
{browsers && (
<BrowserReport
year={year}
param={param}
formatDay={paramInfo.formatDay}
startOffset={paramInfo.startOffset}
browserData={browsers}
labelMapper={paramInfo.labelMapper}
/>
)}
<div className={styles.reportChartsRow}>
<PieChart width={500} height={400}>
<Pie data={pages} dataKey="count" fill="#0074d9">
{pages.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={PIE_COLORS[index % PIE_COLORS.length]}
/>
))}
</Pie>
<Tooltip content={<ReportPageTooltip />} />
</PieChart>
<table>
<thead>
<tr>
<th>Page</th>
<th style={{ textAlign: "right" }}>Views</th>
</tr>
</thead>
<tbody>
{pages.map((page, index) => (
<tr key={index.toString()}>
<td>{page.page}</td>
<td style={{ textAlign: "right" }}>
{formatNumber(page.count, 0)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};

View File

@ -1,47 +0,0 @@
@import "../../styles/colors.scss";
.signInFormContainer {
display: flex;
flex-direction: column;
justify-content: center;
}
.signInForm {
display: flex;
flex-direction: column;
margin: 5em 10em;
padding: 1em;
background-color: $primary-background;
button {
margin: 0.5em 0;
}
}
.field {
display: flex;
flex-direction: row;
margin: 0.5em 0;
label {
width: 25%;
padding: 1rem 0;
color: white;
font-weight: bold;
}
input {
width: 75%;
}
}
.error {
color: $color-red;
font-weight: 600;
text-align: center;
min-height: 1.6em;
}

View File

@ -1,80 +0,0 @@
import React, { FC, useState } from "react";
import { authenticate } from "../../lib/analytics";
import styles from "./SignIn.module.scss";
const SignIn: FC<{ setToken: (token: string) => void }> = ({ setToken }) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = username.length > 1 && password.length > 1;
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
setError(null);
setProcessing(true);
authenticate(username, password)
.then((token) => setToken(token))
.catch((err) => {
console.log("Sign in API error", err, typeof err);
setProcessing(false);
setError(err);
});
};
const handleUsernameChange: React.ChangeEventHandler<HTMLInputElement> = (
event
) => {
setUsername(event.target.value);
};
const handlePasswordChange: React.ChangeEventHandler<HTMLInputElement> = (
event
) => {
setPassword(event.target.value);
};
return (
<div className={styles.signInFormContainer}>
<form
className={styles.signInForm}
noValidate
autoComplete="off"
onSubmit={handleFormSubmit}
>
<div className={styles.field}>
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
name="username"
placeholder="Username"
disabled={processing}
value={username}
onChange={handleUsernameChange}
/>
</div>
<div className={styles.field}>
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Password"
disabled={processing}
value={password}
onChange={handlePasswordChange}
/>
</div>
<div className={styles.error}>{error || " "}</div>
<button type="submit" disabled={!canSubmit || processing}>
Sign In
</button>
</form>
</div>
);
};
export default SignIn;

View File

@ -1,46 +0,0 @@
import React, { FC } from "react";
import {
getWeekViews,
getBrowsersWeek,
getWeekPageCount,
} from "../../lib/analytics";
import { getISOWeek } from "../../lib/utils";
import { Report } from "./Report";
const WEEK_LABELS: string[] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const WeeklyReport: FC<{ paths: string[]; token: string }> = ({
paths,
token,
}) => {
return (
<Report
paths={paths}
paramInfo={{
min: 1,
max: 52,
startOffset: 0,
labelMapper: (day) => WEEK_LABELS[day],
fromDate: getISOWeek,
format: (year, week) => `${year.toString()} W${week.toString()}`,
formatDay: (year, week, category) =>
`${year.toString()} W${week.toString()} ${category}`,
}}
getData={async (path, year, week) => {
const data = (await getWeekViews(token, path, year, week)).map(
(item) => ({
category: WEEK_LABELS[item.day],
views: item.count || 0,
scroll: item.scroll || 0,
duration: item.duration || 0,
})
);
const browsers = (await getBrowsersWeek(token, year, week)).browsers;
const pages = await getWeekPageCount(token, year, week);
return { data, browsers, pages };
}}
/>
);
};
export default WeeklyReport;

View File

@ -1,698 +0,0 @@
@import "../../styles/colors.scss";
@import "../../styles/fonts.scss";
@import "../../styles/tools.scss";
.header {
position: relative;
margin: 0 auto;
padding: 70px 70px 0px;
}
.headerWithImage {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
background-attachment: fixed;
.headerInner {
display: flex;
flex-direction: column;
align-items: center;
.title {
color: rgba(255, 255, 255, 0.9);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
}
.excerpt {
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
}
.details {
background: white;
border-bottom: 1px solid white;
border-radius: 5px 5px 0 0;
width: 100%;
padding-left: 40px;
padding-right: 40px;
}
}
}
.headerInner {
}
.title {
margin: 0 0 0.2em 0;
font-size: 5.5rem;
font-weight: 600;
line-height: 1.15;
}
.excerpt {
margin: 20px 0 0 0;
color: white;
font-family: $text-font-family;
font-size: 2.3rem;
line-height: 1.4em;
font-weight: 300;
}
.details {
margin-top: 4rem;
padding-top: 2rem;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.content {
position: relative;
z-index: 20;
background-color: white;
margin: 0 auto;
padding: 50px 70px 6vw;
min-height: 230px;
font-family: $text-font-family;
font-size: 2rem;
line-height: 1.6em;
h1,
h2,
h3,
h4,
h5,
h6 {
min-width: 100%;
color: darken($color-dark-grey, 5%);
font-family: $body-font-family;
&:hover {
> a {
> span {
position: relative;
&:before {
content: "";
position: absolute;
left: -1em;
top: 0.125em;
width: 1em;
height: 1em;
opacity: 0.5;
background-color: darken($color-mid-grey, 30%);
mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16"><path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/><path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/></svg>');
mask-repeat: no-repeat;
mask-size: contain;
}
}
}
}
}
h1,
h2,
h3,
h4 {
+ figure {
margin-top: 2em;
}
}
h1 {
margin: 0.5em 0 0.4em;
font-size: 4.2rem;
line-height: 1.25em;
font-weight: 600;
}
h2 {
margin: 0.5em 0 0.4em;
font-size: 3.2rem;
line-height: 1.25em;
font-weight: 600;
+ h3 {
margin-top: 0.7em;
}
+ h4 {
margin-top: 0.7em;
}
}
h3 {
margin: 0.5em 0 0.2em;
font-size: 2.5rem;
line-height: 1.3em;
font-weight: 600;
+ h4 {
margin-top: 0;
}
}
h4 {
margin: 0.5em 0 0.2em;
font-size: 2.5rem;
font-weight: 600;
}
h5 {
margin: 0.5em 0;
padding: 0.3em 1em 0.9em;
font-size: 3.2rem;
line-height: 1.35em;
text-align: center;
color: $color-blue;
}
h6 {
margin: 0.5em 0 0.2em 0;
font-size: 2rem;
font-weight: 700;
}
a {
color: $color-dark-grey;
word-break: break-word;
box-shadow: $color-dark-grey 0 -1px 0 inset;
&:hover {
color: $color-blue;
text-decoration: none;
box-shadow: $color-blue 0 -1px 0 inset;
}
&[data-footnote-backref="true"] {
box-shadow: none;
color: $color-light-grey;
}
> code {
text-decoration: underline;
}
}
p {
margin: 0 0 1.5em 0;
min-width: 100%;
+ h1,
+ h2 {
margin-top: 0.8em;
}
}
ul,
ol,
dl {
margin: 0 0 1.5em 0;
max-width: 100%;
align-self: flex-start;
}
li {
word-break: break-word;
margin: 0.5em 0;
padding-left: 0.3em;
line-height: 1.6em;
p {
margin: 0;
}
&:first-child {
margin-top: 0;
}
}
pre {
margin: 0;
padding: 20px;
overflow-x: auto;
border: 1px darken($color-light-grey, 10%) solid;
border-radius: 5px;
color: #383a42;
background-color: rgb(255, 247, 236);
font-size: 1.5rem;
line-height: 1.5em;
&::selection {
color: darken($color-mid-grey, 25%);
}
code {
display: block;
padding: 0;
font-size: inherit;
line-height: inherit;
background: transparent;
&.language-box-drawing,
&:not([class]) {
line-height: 1.3em;
}
&:not(span) {
color: inherit;
}
}
}
blockquote {
margin: 0 0 1.5em;
padding: 0 1.5em;
min-width: 100%;
border-left: 3px solid #3eb0ef;
font-style: italic;
color: $color-mid-grey;
p {
margin: 0 0 1em 0;
color: inherit;
font-size: inherit;
font-style: italic;
line-height: inherit;
&:last-child {
margin-bottom: 0;
}
}
}
figure {
&:global(.code) {
width: 100%;
margin: 0.8em 0 2.3em;
figcaption {
margin: 1em 0 0;
color: darken($color-mid-grey, 10%);
font-family: $body-font-family;
font-size: 75%;
line-height: 1.5em;
text-align: center;
}
}
}
strong,
em {
color: darken($color-dark-grey, 5%);
}
small {
display: inline-block;
line-height: 1.6em;
}
video {
display: block;
margin: 1.5em auto;
max-width: 1040px;
height: auto;
}
code {
padding: 0 5px 2px;
font-size: 0.8em;
line-height: 1em;
font-weight: 400 !important;
background-color: lighten($color-light-grey, 15%);
}
hr {
margin: 2em 0;
&:after {
content: "";
position: absolute;
top: -15px;
left: 50%;
display: block;
margin-left: -10px;
width: 1px;
height: 30px;
background-color: lighten($color-light-grey, 10%);
box-shadow: #ffffff 0 0 0 5px;
transform: rotate(45deg);
}
+ p {
margin-top: 1.2em;
}
}
table {
border-collapse: collapse;
margin: 0.5em 0 2.5em;
font-size: 0.9em;
font-family: $body-font-family;
min-width: 50%;
max-width: 100%;
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.15);
th,
td {
padding: 1rem 1.5rem;
}
thead {
tr {
background-color: $primary-background;
color: white;
text-align: left;
}
}
tbody {
tr {
&:not(:last-child) {
border-bottom: 1px solid $color-light-grey;
}
&:nth-of-type(even) {
background-color: rgba(0, 0, 0, 0.1);
}
&:last-child {
border-bottom-color: $color-mid-grey;
}
}
}
}
section[data-footnotes="true"] {
width: 100%;
}
}
.contentInner {
display: flex;
flex-direction: column;
align-items: center;
}
@media (prefers-color-scheme: dark) {
.headerWithImage {
.headerInner {
.details {
background-color: $dark-mode-background;
border-bottom-color: $dark-mode-background;
}
}
}
.content {
background-color: $dark-mode-background;
h1,
h2,
h3,
h4,
h5,
h6 {
color: rgba(255, 255, 255, 0.9);
&:hover {
> a {
> span {
&:before {
background-color: white;
}
}
}
}
}
a {
color: white;
box-shadow: inset 0 -1px 0 white;
&[data-footnote-backref="true"] {
color: $color-mid-grey;
}
}
strong,
em {
color: rgba(255, 255, 255, 0.75);
}
blockquote {
color: lighten($color-mid-grey, 30%);
}
pre {
background-color: #282c34;
border-color: darken($color-dark-grey, 10%);
color: #c8ced7;
}
code {
color: white;
background: black;
}
figure {
&:global(.code) {
figcaption {
color: rgba(255, 255, 255, 0.6);
}
}
}
table {
tbody {
tr {
&:not(:last-child) {
border-bottom: 1px solid $color-mid-grey;
}
&:nth-of-type(even) {
background-color: rgba(255, 255, 255, 0.05);
}
&:last-child {
border-bottom-color: $color-dark-grey;
}
}
}
}
}
}
@media (min-width: 1180px) {
.content {
h5 {
max-width: 1060px;
width: 100vw;
}
}
}
@media (max-width: 1170px) {
.header {
padding: 60px 11vw 0;
}
.content {
padding: 0 11vw;
}
}
@media (max-width: 800px) {
.header {
padding-left: 5vw;
padding-right: 5vw;
}
.content {
padding: 50px 5vw;
font-size: 1.8rem;
h1 {
font-size: 3.2rem;
line-height: 1.25em;
+ figure {
margin-top: 0.9em;
}
}
h2 {
margin-bottom: 0.3em;
font-size: 2.8rem;
line-height: 1.25em;
+ figure {
margin-top: 0.9em;
}
}
h3 {
margin-bottom: 0.3em;
font-size: 2.4rem;
line-height: 1.3em;
+ figure {
margin-top: 0.9em;
}
}
h4 {
margin-bottom: 0.3em;
font-size: 2.4rem;
line-height: 1.3em;
+ figure {
margin-top: 0.9em;
}
}
h5 {
margin-bottom: 1em;
margin-left: 1.3em;
padding: 0 0 0.5em;
font-size: 2.4rem;
text-align: initial;
}
h6 {
font-size: 1.8rem;
line-height: 1.4em;
}
figure {
margin: 0.2em 0 1.3em;
}
}
}
@media (max-width: 500px) {
.header {
padding: 20px 0 0 0;
}
.title {
font-size: 3.3rem;
}
.headerWithImage {
.headerInner {
.details {
box-sizing: border-box;
padding-left: 0.5em;
padding-right: 0.5em;
}
}
}
.details {
flex-direction: column;
}
.excerpt {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.content {
padding: 35px 0.5rem 0 0.5rem;
p,
ul,
ol,
dl,
pre {
margin-bottom: 1.28em;
}
blockquote {
padding: 0 1.3em;
}
}
}
@media print {
.header {
padding-top: 10px;
}
.headerWithImage {
.title {
font-size: 4rem;
text-shadow: none;
}
.excerpt {
text-shadow: none;
font-size: 2.1rem;
}
}
.content {
p,
li,
blockquote {
font-size: 1.6rem;
line-height: 1.6em;
}
h1,
h2 {
font-size: 3.2rem;
}
h3,
h4,
h5 {
font-size: 2.2rem;
}
h6 {
font-size: 2rem;
}
pre {
background-color: transparent;
}
a {
text-decoration: none;
box-shadow: none;
}
code {
padding: none;
font-size: 1em;
background-color: transparent;
}
a > code {
text-decoration: none;
}
}
}

View File

@ -1,92 +0,0 @@
import React, { FC } from "react";
import cn from "classnames";
import { useRouter } from "next/router";
import { MDXRemoteSerializeResult } from "next-mdx-remote";
import { DocInfo } from "../../lib/content";
import { Tag } from "../../lib/tags";
import { ScrollToTopButton } from "../fields/ScrollToTop";
import { PostDetails } from "../PostDetails";
import { TagList } from "../display/TagList";
import { Render } from "./Render";
import styles from "./Content.module.scss";
import { GitLogEntry } from "../../lib/git";
import RevisionHistory from "./RevisionHistory";
const ContentHeader: FC<{
tags?: Tag[];
doc: DocInfo;
featureImage?: string;
}> = ({ tags, doc, featureImage }) => {
return (
<header
className={cn(styles.header, styles.outer, {
[styles.headerWithImage]: Boolean(featureImage),
})}
style={{
backgroundImage: featureImage
? `linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url(${featureImage})`
: undefined,
}}
>
<div className={cn(styles.headerInner, styles.inner)}>
<h1 className={styles.title}>{doc.title}</h1>
{doc.excerpt ? <p className={styles.excerpt}>{doc.excerpt}</p> : null}
<div className={styles.details}>
<PostDetails doc={doc} />
{tags && <TagList tags={tags} large />}
</div>
</div>
</header>
);
};
const ContentBody: FC<{
content: MDXRemoteSerializeResult;
history: GitLogEntry[];
}> = ({ content, history }) => {
const router = useRouter();
const highlight =
"s" in router.query &&
typeof router.query["s"] === "string" &&
router.query["s"].length > 0
? router.query["s"]
: undefined;
return (
<React.Fragment>
<div className={cn(styles.content, styles.outer)}>
<div className={cn(styles.contentInner, styles.inner)}>
<Render content={content} query={highlight} />
{history.length > 0 && <RevisionHistory history={history} />}
</div>
</div>
<ScrollToTopButton />
</React.Fragment>
);
};
export interface ContentProps {
tags?: Tag[];
doc: DocInfo;
featureImage?: string;
content: MDXRemoteSerializeResult;
history?: GitLogEntry[];
}
export const Content: FC<ContentProps> = ({
tags,
doc,
featureImage,
content,
history = [],
}) => {
return (
<article className="post">
<ContentHeader tags={tags} doc={doc} featureImage={featureImage} />
<ContentBody content={content} history={history} />
</article>
);
};

View File

@ -1,49 +0,0 @@
@import "../../styles/colors.scss";
@import "../../styles/fonts.scss";
.imageCard {
margin: 0.8em 0 2.3em;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
figcaption {
margin: 1em 0 0;
color: darken($color-mid-grey, 10%);
font-family: $body-font-family;
font-size: 75%;
line-height: 1.5em;
text-align: center;
}
}
.imageCardImage {
}
.imageCardWide {
img {
max-width: 1040px;
}
}
.imageCardFull {
img {
max-width: 100vw;
}
figcaption {
padding: 0 1.5em;
}
}
@media (max-width: 1170px) {
.imageCard {
img {
max-width: 100%;
}
}
}

View File

@ -1,623 +0,0 @@
import React, {
DetailedHTMLProps,
FC,
HTMLAttributes,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
import Image from "../display/Image";
import PreparedIndex from "../../lib/search/index/prepared";
import { Range } from "../../lib/search/tree/node";
import Load from "../../lib/search/encoding/load";
import Bookmark from "./components/Bookmark";
import Quote from "./components/Quote";
import { AnalyticsInformation } from "../Analytics";
import styles from "./Render.module.scss";
// Nodes are generated that include a `path` property that is a string containing a comma-separated list of numbers.
// This is used to number the elements in the document, and is used to perform index operations (like search
// highlighting).
interface PathProps {
path: number[];
}
// When we're highlighting matching search terms, we want to store the search terms that we've loaded from the
// `PreparedIndex`. An array of this structure is stored in the `LoadedSearchPositionContext`. We refer to this when we
// perform our search highlighting. Each of these objects contains the path to the document element and the set of
// ranges within that element.
interface LoadedSearchPosition {
path: number[];
ranges: Range[];
}
const LoadedSearchPositionsContext = React.createContext<
LoadedSearchPosition[]
>([]);
function decodeQuery(query?: string): { docId: number; term: string } {
if (typeof query === "string") {
return JSON.parse(query);
}
return { docId: -1, term: "" };
}
const SearchPositionsProvider: FC<
React.PropsWithChildren<{ query?: string }>
> = ({ query, children }) => {
const { docId, term } = decodeQuery(query);
const [index, setIndex] = useState<PreparedIndex | null>(null);
const positions = useMemo(() => {
if (term.length === 0 || index === null) {
return [];
}
const results = index.search(term, docId);
const loaded: LoadedSearchPosition[] = [];
for (const position of results.get(docId) || []) {
const location = index.locations.getLocation(position.location_id);
if (location) {
loaded.push({
path: location.path,
ranges: position.ranges.sort((a, b) => a.start - b.start),
});
}
}
return loaded;
}, [index !== null, query || ""]);
useEffect(() => {
if (typeof query !== "string" || query.length === 0) {
return;
}
const abort = new AbortController();
void (async function () {
try {
const res = await fetch("/data/search.bin", { signal: abort.signal });
const index = PreparedIndex.load(new Load(await res.arrayBuffer()));
setIndex(index);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Ignore abort errors.
return;
}
console.error(err);
}
})();
return () => {
abort.abort();
};
}, [query || ""]);
return (
<LoadedSearchPositionsContext.Provider value={positions}>
{children}
</LoadedSearchPositionsContext.Provider>
);
};
// Given the array of `LoadedSearchPosition` (usually from the `LoadedSearchPositionsContext`) and the path to the
// current node (usually from `PathProps`), return any highlight ranges in that node.
function getSearchRanges(
positions: LoadedSearchPosition[],
path: number[]
): Range[] {
for (const position of positions) {
if (position.path.length === path.length) {
let match = true;
for (let i = 0; i < position.path.length; i++) {
if (position.path[i] !== path[i]) {
match = false;
break;
}
}
if (match) {
return position.ranges;
}
}
}
return [];
}
function splitRanges(ranges: Range[], offset: number): Range[] {
const result: Range[] = [];
for (const range of ranges) {
if (range.start < offset) {
if (range.start + range.length > offset) {
result.push({
start: range.start,
length: offset - range.start,
});
}
} else {
result.push({
start: range.start - offset,
length: range.length,
});
}
}
return result;
}
// Standard trivial highlighter that takes a set of ranges and a string and returns a React component tree in which the
// text is highlighted using `<mark>` elements at various ranges.
function renderHighlight(ranges: Range[], text: string): React.ReactElement {
const parts: React.ReactElement[] = [];
let start_index = 0;
for (const range of ranges) {
const prefix = text.substring(start_index, range.start);
if (prefix.length > 0) {
parts.push(
<React.Fragment key={parts.length.toString()}>{prefix}</React.Fragment>
);
}
const highlighted = text.substring(range.start, range.start + range.length);
if (highlighted.length > 0) {
parts.push(<mark key={parts.length.toString()}>{highlighted}</mark>);
}
start_index = range.start + range.length;
}
const suffix = text.substring(start_index);
if (suffix.length > 0) {
parts.push(
<React.Fragment key={parts.length.toString()}>{suffix}</React.Fragment>
);
}
if (parts.length > 0) {
return <React.Fragment>{parts}</React.Fragment>;
} else {
return <React.Fragment>{text}</React.Fragment>;
}
}
// This function examines an anonymous 'props' object to see if it contains a `data-path` property. If so, and the
// property value is a string, then it is split into an array of numbers and returned in a `PathProps` interface.
function expandDataPathProps(props: object): PathProps {
if (typeof props === "object" && "data-path" in props) {
return {
path: ((props as any)["data-path"] as string)
.split(",")
.map((n) => parseInt(n, 10)),
};
}
return { path: [] };
}
// Given some phrasing content (similar to a `#text` node), attempt to highlight the contents. This will only perform
// this operation if the `LoadedSearchPositionsContext` actually contains any positions; otherwise it will just return
// the children in a `React.Fragment`.
const RenderPhrasingChildren: FC<React.PropsWithChildren<PathProps>> = ({
path,
children,
}) => {
// Get the loaded search highlight positions.
const positions = useContext(LoadedSearchPositionsContext);
// If there are no children, then there's nothing to do.
if (typeof children === "undefined") {
return null;
}
// If we don't have any positions, just return the children in a fragment.
if (positions.length === 0) {
return <>{children}</>;
}
// If the `children` is just a string, then use the trivial `renderHighlight` to highlight all the ranges that match
// the current path (if any). Note that `renderHighlight` will basically do nothing if `getSearchRanges` returns an
// empty array.
if (typeof children === "string") {
return renderHighlight(getSearchRanges(positions, [...path, 0]), children);
}
// If the `children` is an array of things, then apply highlighting to all the children.
if (children instanceof Array) {
return (
<>
{children.map((child, index) => {
if (typeof child === "string") {
return (
<React.Fragment key={index.toString()}>
{renderHighlight(
getSearchRanges(positions, [...path, index]),
child
)}
</React.Fragment>
);
} else {
// The child is not a string, so it could be a normal react element. In which case we can just leave it
// as-is: if it's something like a `<em>` element, it will have been replaced with our `RenderEmphasis`
// component.
return child;
}
})}
</>
);
}
// We don't know what to do here: `children` is neither a string nor an array.
return <>{children}</>;
};
// Render an <em> element, but render it's children using `RenderPhrasingChildren`.
const RenderEmphasis: (
props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
) => JSX.Element = ({ children, ...props }) => {
return (
<em>
<RenderPhrasingChildren {...expandDataPathProps(props)}>
{children}
</RenderPhrasingChildren>
</em>
);
};
// Render a <strong> element, but render it's children using `RenderPhrasingChildren`.
const RenderStrong: (
props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
) => JSX.Element = ({ children, ...props }) => {
return (
<strong>
<RenderPhrasingChildren {...expandDataPathProps(props)}>
{children}
</RenderPhrasingChildren>
</strong>
);
};
// Render an <li> element, but render it's children using `RenderPhrasingChildren`.
const RenderListItem: (
props: DetailedHTMLProps<HTMLAttributes<HTMLLIElement>, HTMLLIElement>
) => JSX.Element = ({ children, ...props }) => {
return (
<li>
<RenderPhrasingChildren {...expandDataPathProps(props)}>
{children}
</RenderPhrasingChildren>
</li>
);
};
// Render an <a> element, but render it's children using `RenderPhrasingChildren`.
const RenderLink: (
props: DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>
) => JSX.Element = ({ children, ...props }) => {
return (
<a {...props}>
<RenderPhrasingChildren {...expandDataPathProps(props)}>
{children}
</RenderPhrasingChildren>
</a>
);
};
// Render a <p> element, but render it's children using `RenderPhrasingChildren`.
const RenderParagraph: (
props: DetailedHTMLProps<
HTMLAttributes<HTMLParagraphElement>,
HTMLParagraphElement
>
) => JSX.Element = ({ children, ...props }) => {
return (
<p>
<RenderPhrasingChildren {...expandDataPathProps(props)}>
{children}
</RenderPhrasingChildren>
</p>
);
};
// Render a <blockquote> element, but render it's children using `RenderPhrasingChildren`.
const RenderBlockQuote: (
props: DetailedHTMLProps<
React.BlockquoteHTMLAttributes<HTMLElement>,
HTMLElement
>
) => JSX.Element = ({ children, ...props }) => {
return (
<blockquote>
<RenderPhrasingChildren {...expandDataPathProps(props)}>
{children}
</RenderPhrasingChildren>
</blockquote>
);
};
// Create a heading at the given level (1..6) that will render a corresponding heading element (e.g. `<h1>`) that uses
// `RenderPhrasingChildren` to render its children.
function createHeading(
level: number
): (
props: DetailedHTMLProps<
HTMLAttributes<HTMLHeadingElement>,
HTMLHeadingElement
>
) => JSX.Element {
if (level < 1 || level > 6) {
throw new Error(`Heading level ${level} is not a valid HTML heading level`);
}
return function headingFunction(props) {
return React.createElement(
`h${level}`,
{ id: props.id },
<RenderPhrasingChildren {...expandDataPathProps(props)}>
{props.children}
</RenderPhrasingChildren>
);
};
}
// This component is used to override the `<img>` element rendering in MDX, to perform a number of changes:
//
// 1. The entire image is wrapped in a `<figure>` and a `<div>` to handle the image positioning.
// 2. We use the Next `<Image>` element (actually our version of it) to render the image.
// 3. If there is an alt-text for the image, we also render that in a `<figcaption>`.
const RenderImage: (
props: DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>
) => JSX.Element = (props) => {
const caption = props.alt && props.alt !== "" ? props.alt : undefined;
return (
<figure className={styles.imageCard}>
<div className={styles.imageCardImage}>
<Image
src={props.src || ""}
width={
typeof props.width === "string"
? parseInt(props.width)
: props.width
}
height={
typeof props.height === "string"
? parseInt(props.height)
: props.height
}
alt={props.alt || ""}
/>
</div>
{caption && <figcaption>{caption}</figcaption>}
</figure>
);
};
interface HighlightRow {
properties: any;
type: "text" | "element";
tagName?: string;
value?: string;
children?: HighlightRow[];
}
function createCodeElement(
positions: Range[],
row: HighlightRow,
index: number,
offset: number
): { element: React.ReactNode; newOffset: number } {
let { properties, type, tagName: TagName, value, children } = row;
if (type === "text") {
if (positions.length > 0 && value) {
return {
element: (
<React.Fragment key={index.toString()}>
{renderHighlight(splitRanges(positions, offset), value)}
</React.Fragment>
),
newOffset: offset + value.length,
};
}
return { element: value, newOffset: offset + (value || "").length };
}
if (TagName) {
let props = {
...properties,
key: index.toString(),
className: properties.className.join(" "),
};
children = children || [];
const childElements = children.map((child, index) => {
const { element, newOffset } = createCodeElement(
positions,
child,
index,
offset
);
offset = newOffset;
return element;
});
return {
element: <TagName {...props}>{childElements}</TagName>,
newOffset: offset,
};
}
return { element: null, newOffset: offset };
}
type CodeRenderer = (input: {
rows: HighlightRow[];
stylesheet: any;
useInlineStyles: boolean;
}) => React.ReactNode;
function getCodeRenderer(positions: Range[]): CodeRenderer {
return ({ rows }): React.ReactNode => {
let offset = 0;
return rows.map((node, index) => {
const { element, newOffset } = createCodeElement(
positions,
node,
index,
offset
);
offset = newOffset;
return element;
});
};
}
const LANGUAGE_RE = /language-(\w+)/;
function extractLanguage(className?: string): string | undefined {
if (typeof className === "string") {
const match = className.match(LANGUAGE_RE);
if (match && match[1]) {
return match[1];
}
}
return undefined;
}
// Somewhat annoyingly, the syntax highlighter we use will always want to encapsulate the code block in a `<pre>`
// element. When we're executing `RenderCodeBlock`, we are _already_ in a `<pre>`, as this was added during hast
// pre-processing.
//
// To avoid nested `<pre>` elements, we can change the element using the `PreTag` property to the syntax highlighter.
// Unfortunately we can't just pass `null` or `undefined`: If we use `null` we'll get an error (quite rightly) from
// `React.createElement` which was called by the syntax highlighter. If we use `undefined` then it's the same as not
// setting the property, and the syntax highlighter will add a `<pre>`.
//
// So, naturally we'd thing to just pass in `React.Fragment`. After all, that's what it is for! Alas this is not going
// to work, as `React.Fragment` will complain (again, quite rightly) that it's being passed a `className` property. As
// it turns out, the syntax highlighter expects to be able to pass a `className` property. This is unfortunate, as the
// type of the `PreTag` property is just `React.ReactNode`, which doesn't tell us about this assumption.
//
// Finally this brings us to `PropIgnoringFragment`, which is just a wrapper around `React.Fragment` that doesn't pass
// any properties. We don't need to acknowledge that the syntax highlighter will pass any properties in our types, as
// the `PropType` property is just a `React.ReactNode`.
const PropIgnoringFragment: FC<React.PropsWithChildren> = ({ children }) => {
return <>{children}</>;
};
const RenderCodeBlock: (
props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
) => JSX.Element = (props) => {
const { path } = expandDataPathProps(props);
const loaded = useContext(LoadedSearchPositionsContext);
const positions = getSearchRanges(loaded, [...path, 0]);
const language = extractLanguage(props.className);
const [highlighter, setHighlighter] = useState<any>(null);
useEffect(() => {
if (language) {
import("./SyntaxHighlight").then((module) => {
setHighlighter(module);
});
}
}, [language]);
if (highlighter && language) {
const SyntaxHighlighter = highlighter.SyntaxHighlighter;
const content = props.children as string;
return (
<SyntaxHighlighter
useInlineStyles={false}
language={language}
renderer={getCodeRenderer(positions)}
PreTag={PropIgnoringFragment}
>
{content.endsWith("\n")
? content.substring(0, content.length - 1)
: content}
</SyntaxHighlighter>
);
}
return (
<code>
<RenderPhrasingChildren {...expandDataPathProps(props)}>
{props.children}
</RenderPhrasingChildren>
</code>
);
};
// This component is used to override the `<code>` element in MDX. This component picks the type of transformation to
// perform based on `className` of the `<code>` element:
//
// 1. If the `className` contains `block`, then this is a code block, and we defer the rendering activity to the
// `RenderCodeBlock` element for special handling.
// 2. Otherwise, we simply render a `<code>` element, but use `RenderPhrasingChildren` to render the contents, which is
// similar to our overrides for `<em>` or `<strong>`.
const RenderCode: (
props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
) => JSX.Element = (props) => {
if (props.className && props.className?.indexOf("block") !== -1) {
return <RenderCodeBlock {...props} />;
} else {
return (
<code>
<RenderPhrasingChildren {...expandDataPathProps(props)}>
{props.children}
</RenderPhrasingChildren>
</code>
);
}
};
export const Render: FC<{
content: MDXRemoteSerializeResult;
query?: string;
}> = ({ content, query: highlight }) => {
const components: any = {
img: RenderImage,
p: RenderParagraph,
blockquote: RenderBlockQuote,
em: RenderEmphasis,
strong: RenderStrong,
code: RenderCode,
li: RenderListItem,
a: RenderLink,
h1: createHeading(1),
h2: createHeading(2),
h3: createHeading(3),
h4: createHeading(4),
h5: createHeading(5),
h6: createHeading(6),
Bookmark: Bookmark,
Quote: Quote,
AnalyticsInformation: AnalyticsInformation,
};
return (
<SearchPositionsProvider query={highlight}>
<MDXRemote {...content} components={components} />
</SearchPositionsProvider>
);
};

View File

@ -1,32 +0,0 @@
@import "../../styles/colors.scss";
.revisionHistory {
width: 100%;
border-top: 1px solid $primary-background;
margin: 5rem 0;
font-size: 80%;
h3 {
font-size: 120%;
}
ul {
list-style: none;
margin: 0;
padding: 0;
li {
margin: 0;
padding: 0;
}
}
}
@media (max-width: 800px) {
.revisionHistory {
margin: 2rem 0;
}
}

View File

@ -1,38 +0,0 @@
import { FC } from "react";
import { GitLogEntry } from "../../lib/git";
import { zeroPad } from "../../lib/utils";
import styles from "./RevisionHistory.module.scss";
export interface RevisionHistoryProps {
history: GitLogEntry[];
}
const renderDate = (input: string): string => {
const date = new Date(input);
return `${zeroPad(date.getFullYear(), 4)}-${zeroPad(
1 + date.getMonth(),
2
)}-${zeroPad(date.getDate(), 2)}`;
};
export const RevisionHistory: FC<RevisionHistoryProps> = ({ history }) => {
return (
<div className={styles.revisionHistory}>
<h3>Revisions</h3>
<ul>
{history.map((entry, index) => (
<li key={index.toString()}>
{renderDate(entry.date)} &mdash;{" "}
<a
href={`https://github.com/BlakeRain/blakerain.com/commit/${entry.hash}`}
>
{entry.message}
</a>
</li>
))}
</ul>
</div>
);
};
export default RevisionHistory;

View File

@ -1,19 +0,0 @@
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import bash from "react-syntax-highlighter/dist/cjs/languages/hljs/bash";
import cpp from "react-syntax-highlighter/dist/cjs/languages/hljs/cpp";
import css from "react-syntax-highlighter/dist/cjs/languages/hljs/css";
import js from "react-syntax-highlighter/dist/cjs/languages/hljs/javascript";
import nginx from "react-syntax-highlighter/dist/cjs/languages/hljs/nginx";
import rust from "react-syntax-highlighter/dist/cjs/languages/hljs/rust";
import python from "react-syntax-highlighter/dist/cjs/languages/hljs/python";
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("cpp", cpp);
SyntaxHighlighter.registerLanguage("css", css);
SyntaxHighlighter.registerLanguage("javascript", js);
SyntaxHighlighter.registerLanguage("nginx", nginx);
SyntaxHighlighter.registerLanguage("rust", rust);
SyntaxHighlighter.registerLanguage("python", python);
export { SyntaxHighlighter };

View File

@ -1,169 +0,0 @@
@import "../../../styles/colors.scss";
@import "../../../styles/fonts.scss";
.bookmark {
width: 100%;
margin: 0.8em 0 2.3em;
}
.bookmarkContainer {
display: flex;
min-height: 148px;
color: $color-dark-grey;
font-family: $body-font-family;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 5px -1px rgba(0, 0, 0, 0.25), 0 0 1px rgba(0, 0, 0, 0.09) !important;
&:hover {
.bookmarkTitle {
color: $color-blue;
}
}
}
.bookmarkContent {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
padding: 20px;
}
.bookmarkTitle {
color: darken($color-mid-grey, 30%);
font-size: 1.6rem;
line-height: 1.65em;
font-weight: 600;
}
.bookmarkDescription {
display: -webkit-box;
overflow-y: hidden;
margin-top: 12px;
max-height: 48px;
color: darken($color-mid-grey, 10%);
font-size: 1.5rem;
line-height: 1.5em;
font-weight: 400;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.bookmarkThumbnail {
position: relative;
min-width: 33%;
max-height: 100%;
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 0 3px 3px 0;
object-fit: cover;
}
}
.bookmarkMetadata {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 14px;
color: darken($color-mid-grey, 10%);
font-size: 1.5rem;
font-weight: 400;
}
.bookmarkIcon {
width: 22px;
height: 22px;
margin-right: 8px;
}
.bookmarkPublisher {
line-height: 1.5em;
&:after {
content: "";
margin: 0 6px;
}
}
.bookmarkAuthor {
overflow: hidden;
max-width: 240px;
line-height: 1.5em;
}
@media (prefers-color-scheme: dark) {
.bookmark {
background: $dark-mode-background;
}
.bookmarkContainer {
color: rgba(255, 255, 255, 0.75);
box-shadow: none !important;
background: $dark-mode-primary;
}
.bookmarkTitle {
color: white;
}
.bookmarkDescription,
.bookmarkMetadata {
color: rgba(255, 255, 255, 0.75);
}
}
@media (max-width: 500px) {
.bookmarkContainer {
flex-direction: column;
}
.bookmarkIcon {
width: 18px;
height: 18px;
}
.bookmarkContent {
order: 2;
}
.bookmarkTitle,
.bookmarkDescription,
.bookmarkMetadata {
font-size: 1.4rem;
line-height: 1.5em;
}
.bookmarkThumbnail {
order: 1;
min-height: 160px;
width: 100%;
img {
border-radius: 3px 3px 0 0;
}
}
}

View File

@ -1,55 +0,0 @@
import React from "react";
import styles from "./Bookmark.module.scss";
export interface BookmarkProps {
url: string;
title: string;
author: string;
description: string;
icon?: string;
publisher?: string;
thumbnail?: string;
}
const Bookmark: (props: BookmarkProps) => JSX.Element = ({
url,
title,
author,
description,
icon,
publisher,
thumbnail,
}) => {
return (
<figure className={styles.bookmark}>
<a className={styles.bookmarkContainer} href={url}>
<div className={styles.bookmarkContent}>
<div className={styles.bookmarkTitle}>{title}</div>
{description && (
<div className={styles.bookmarkDescription}>{description}</div>
)}
<div className={styles.bookmarkMetadata}>
{icon && (
<img
className={styles.bookmarkIcon}
alt={publisher || undefined}
src={icon}
/>
)}
{publisher && (
<span className={styles.bookmarkPublisher}>{publisher}</span>
)}
{author && <span className={styles.bookmarkAuthor}>{author}</span>}
</div>
</div>
{thumbnail && (
<div className={styles.bookmarkThumbnail}>
<img src={thumbnail} alt={title} loading="lazy" decoding="async" />
</div>
)}
</a>
</figure>
);
};
export default Bookmark;

View File

@ -1,61 +0,0 @@
@import "../../../styles/colors.scss";
@import "../../../styles/fonts.scss";
.quote {
position: relative;
margin: 0 0 1.5em;
padding: 0 1.5em;
min-width: 100%;
font-style: italic;
color: $color-mid-grey;
&:before {
position: absolute;
top: 16px;
left: -20px;
display: block;
content: "\201C";
font-size: 8rem;
}
p {
margin: 0 0 1em 0;
color: inherit;
font-size: inherit;
font-style: italic;
line-height: inherit;
&:last-of-type {
margin-bottom: 0;
}
}
cite {
font-family: $body-font-family;
font-size: 1.8rem;
&:before {
content: "\2014 \2009";
}
}
}
@media (prefers-color-scheme: dark) {
.quote {
color: lighten($color-mid-grey, 30%);
}
}
@media (max-width: 500px) {
.quote {
padding: 0;
&:before {
display: none;
}
}
}

View File

@ -1,32 +0,0 @@
import { PropsWithChildren } from "react";
import styles from "./Quote.module.scss";
export interface QuoteProps {
url?: string;
author?: string;
}
const Quote: (props: PropsWithChildren<QuoteProps>) => JSX.Element = ({
url,
author,
children,
}) => {
return (
<div className={styles.quote}>
{children}
{author && (
<cite className={styles.quoteAuthor}>
{url ? (
<a href={url} target="_blank" rel="noreferrer">
{author}
</a>
) : (
author
)}
</cite>
)}
</div>
);
};
export default Quote;

View File

@ -1,32 +0,0 @@
@import "../../styles/colors.scss";
.card {
border-radius: 5px;
border: 1px solid $color-light-grey;
box-shadow: 2px 2px 4px adjust-color($color-light-grey, $alpha: 0.5);
}
.cardTitle {
border-bottom: 1px solid $color-light-grey;
padding: 1rem;
h4 {
margin: 0;
}
}
.cardBody {
padding: 1rem;
}
@media (prefers-color-scheme: dark) {
.card {
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
border-color: $color-mid-grey;
background-color: lighten($dark-mode-background, 5%);
}
.cardTitle {
border-bottom-color: $color-mid-grey;
}
}

View File

@ -1,24 +0,0 @@
import React, { FC } from "react";
import styles from "./Card.module.scss";
export interface CardProps {
title?: string;
}
export const Card: FC<React.PropsWithChildren<CardProps>> = ({
title,
children,
}) => {
return (
<div className={styles.card}>
{title && (
<div className={styles.cardTitle}>
<h4>{title}</h4>
</div>
)}
<div className={styles.cardBody}>{children}</div>
</div>
);
};
export default Card;

View File

@ -1,17 +0,0 @@
import React, { FC, useEffect, useState } from "react";
const ClientOnly: FC<any> = ({ children, ...delegated }) => {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return null;
}
return <div {...delegated}>{children}</div>;
};
export default ClientOnly;

View File

@ -1,27 +0,0 @@
import { FC } from "react";
const MONTH_NAMES = [
"JAN",
"FEB",
"MAR",
"APR",
"MAY",
"JUN",
"JUL",
"AUG",
"SEP",
"OCT",
"NOV",
"DEC",
];
export const DateSpan: FC<{ date: string }> = ({ date }) => {
const date_obj = new Date(date);
return (
<span>
{date_obj.getDate()} {MONTH_NAMES[date_obj.getMonth()]}{" "}
{date_obj.getFullYear()}
</span>
);
};

View File

@ -1,24 +0,0 @@
.floatingLabel {
display: flex;
flex-direction: column;
h4 {
margin: 0;
font-weight: normal;
small {
display: inline;
}
}
.floatingLabelBody {
display: flex;
flex-direction: column;
}
}
.floatingLabelRow {
.floatingLabelBody {
flex-direction: row;
}
}

View File

@ -1,31 +0,0 @@
import React, { FC } from "react";
import cn from "classnames";
import styles from "./FloatingLabel.module.scss";
export interface FloatingLabelProps {
title: React.ReactNode;
className?: string;
row?: boolean;
}
export const FloatingLabel: FC<React.PropsWithChildren<FloatingLabelProps>> = ({
title,
className,
row = false,
children,
}) => {
return (
<div
className={cn(
styles.floatingLabel,
{ [styles.floatingLabelRow]: row },
className
)}
>
<h4>{title}</h4>
<div className={styles.floatingLabelBody}>{children}</div>
</div>
);
};
export default FloatingLabel;

View File

@ -1,3 +0,0 @@
.grid {
display: grid;
}

View File

@ -1,76 +0,0 @@
import React, { FC } from "react";
import cn from "classnames";
import styles from "./Grid.module.scss";
export interface GridProps {
rows?: number | string[];
columns?: number | string[];
rowGap?: number;
columnGap?: number;
mt?: number;
mr?: number;
mb?: number;
ml?: number;
className?: string;
style?: React.CSSProperties;
}
export const Grid: FC<React.PropsWithChildren<GridProps>> = ({
rows,
columns,
rowGap,
columnGap,
mt,
mr,
mb,
ml,
style,
className,
children,
}) => {
const computedStyle: React.CSSProperties = { ...style };
if (typeof rows === "number") {
computedStyle.gridTemplateRows = `repeat(${rows}, 1fr)`;
} else if (rows instanceof Array) {
computedStyle.gridTemplateRows = rows.join(" ");
}
if (typeof columns === "number") {
computedStyle.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`;
} else if (columns instanceof Array) {
computedStyle.gridTemplateColumns = columns.join(" ");
}
if (typeof rowGap === "number") {
computedStyle.rowGap = `${rowGap}rem`;
}
if (typeof columnGap === "number") {
computedStyle.columnGap = `${columnGap}rem`;
}
if (typeof mt === "number") {
computedStyle.marginTop = `${mt}rem`;
}
if (typeof mr === "number") {
computedStyle.marginRight = `${mr}rem`;
}
if (typeof mb === "number") {
computedStyle.marginBottom = `${mb}rem`;
}
if (typeof ml === "number") {
computedStyle.marginLeft = `${ml}rem`;
}
return (
<div className={cn(styles.grid, className)} style={computedStyle}>
{children}
</div>
);
};
export default Grid;

View File

@ -1,64 +0,0 @@
import React, { useState } from "react";
import path from "path";
import NextImage, { ImageProps } from "next/image";
interface SplitFilePath {
dir: string;
filename: string;
extension: string;
}
const splitFilePath: (file_path: string) => SplitFilePath = (file_path) => {
return {
dir: path.dirname(file_path),
filename: path.basename(file_path),
extension: path.extname(file_path),
};
};
const isImageExtension: (extension: string) => Boolean = (extension) => {
return [".jpg", ".jpeg", ".webp", ".png", ".avif"].includes(
extension.toLowerCase()
);
};
const customLoader: (props: { src: string; width: number }) => string = ({
src,
width,
}) => {
const { dir, filename, extension } = splitFilePath(src);
if (!isImageExtension(extension)) {
// The image has an unsupported extension
return src;
}
// We are going to use WEBP for all image formats
let target_ext = extension;
if (target_ext.toLowerCase() != ".webp") {
target_ext = ".webp";
}
const target = path.join(
dir,
"optimized",
`${path.basename(filename, extension)}-opt-${width}${target_ext}`
);
return target;
};
export default function Image(props: ImageProps): JSX.Element {
const [imageError, setImageError] = useState(false);
const { src, ...rest } = props;
return (
<NextImage
{...rest}
loader={imageError ? ({ src }) => src : customLoader}
src={src}
onError={() => {
setImageError(true);
}}
/>
);
}

View File

@ -1,60 +0,0 @@
.tagList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
text-transform: uppercase;
font-size: 1.2rem;
line-height: 1.4em;
font-weight: 400;
li {
position: relative;
display: block;
margin-right: 10px;
font-weight: 600;
&:after {
content: "";
display: block;
position: absolute;
top: 7px;
right: -6px;
width: 2px;
height: 2px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 100%;
}
&:last-of-type {
margin-right: 0;
&:after {
display: none;
}
}
}
}
.tagListLarge {
font-size: 1.3rem;
line-height: 1.6em;
font-weight: 600;
li {
margin-right: 20px;
&:after {
top: 8px;
right: -12px;
width: 4px;
height: 4px;
}
}
}

View File

@ -1,29 +0,0 @@
import { FC } from "react";
import Link from "next/link";
import { Tag } from "../../lib/tags";
import cn from "classnames";
import styles from "./TagList.module.scss";
export const TagList: FC<{
tags: Tag[];
large?: boolean;
}> = ({ tags, large }) => {
return (
<ul
className={cn({
[styles.tagList]: true,
[styles.tagListLarge]: large,
})}
>
{tags
.filter((tag) => tag.visibility === "public")
.map((tag, index) => (
<li key={index.toString()}>
<Link href={"/tags/" + tag.slug} title={tag.description || ""}>
{tag.name}
</Link>
</li>
))}
</ul>
);
};

View File

@ -1,126 +0,0 @@
@import "../../styles/colors.scss";
.tooltip {
position: relative;
display: inline-block;
top: 2px;
margin-left: 0.5rem;
cursor: pointer;
color: rgba(255, 255, 255, 0.25);
&:hover {
color: lighten($primary-background, 20%);
}
}
@media (max-width: 800px) {
.tooltip {
display: none;
}
}
.tooltipBody {
position: absolute;
z-index: 100;
width: max-content;
max-width: 200px;
padding: 1rem;
text-align: center;
color: white;
text-decoration: none;
font-weight: normal;
border-radius: 5px;
background-color: $primary-background;
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
&:after {
content: "";
position: absolute;
width: 0;
height: 0;
}
}
.tooltipBody_top {
bottom: 100%;
left: 50%;
margin-left: -100px;
margin-bottom: 1rem;
&:after {
top: 100%;
left: 50%;
margin-left: -1rem;
border-left: 1rem solid transparent;
border-right: 1rem solid transparent;
border-top: 1rem solid $primary-background;
}
}
.tooltipBody_bottom {
top: 100%;
left: 50%;
margin-left: -100px;
margin-top: 1rem;
&:after {
bottom: 100%;
left: 50%;
margin-left: -1rem;
border-left: 1rem solid transparent;
border-right: 1rem solid transparent;
border-bottom: 1rem solid $primary-background;
}
}
.tooltipBody_left {
top: -5px;
right: 100%;
margin-right: 1rem;
&:after {
top: 5px;
left: 100%;
margin-right: 1rem;
border-top: 1rem solid transparent;
border-bottom: 1rem solid transparent;
border-left: 1rem solid $primary-background;
}
}
.tooltipBody_right {
top: -5px;
left: 100%;
margin-left: 1rem;
&:after {
top: 5px;
right: 100%;
margin-left: 1rem;
border-top: 1rem solid transparent;
border-bottom: 1rem solid transparent;
border-right: 1rem solid $primary-background;
}
}

View File

@ -1,42 +0,0 @@
import React, { FC, useState } from "react";
import cn from "classnames";
import styles from "./Tooltip.module.scss";
import QuestionCircle from "../icons/QuestionCircle";
export interface TooltipProps {
position?: "top" | "left" | "bottom" | "right";
}
export const Tooltip: FC<React.PropsWithChildren<TooltipProps>> = ({
position = "top",
children,
}) => {
const [open, setOpen] = useState(false);
const onMouseOver: React.MouseEventHandler<HTMLDivElement> = () => {
setOpen(true);
};
const onMouseOut: React.MouseEventHandler<HTMLDivElement> = () => {
setOpen(false);
};
return (
<div
className={styles.tooltip}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
>
<QuestionCircle />
{open && (
<div
className={cn(styles.tooltipBody, styles[`tooltipBody_${position}`])}
>
{children}
</div>
)}
</div>
);
};
export default Tooltip;

View File

@ -1,42 +0,0 @@
import React, { FC } from "react";
import { Currency, CURRENCY_SYMBOLS, CURRENCIES } from "../../lib/tools/position-size/forex";
export interface CurrencySelectProps {
value: Currency;
exclude?: Currency;
onChange?: (currency: Currency) => void;
id?: string;
disabled?: boolean;
}
const CurrencySelect: FC<CurrencySelectProps> = ({
value,
exclude,
onChange,
id,
disabled,
}) => {
const options = CURRENCIES.filter((currency) => currency !== exclude).map(
(currency, index) => (
<option key={index} value={currency}>
{currency} ({CURRENCY_SYMBOLS.get(currency)})
</option>
)
);
const onSelectChange: React.ChangeEventHandler<HTMLSelectElement> = (
event
) => {
if (onChange) {
onChange(event.target.value as Currency);
}
};
return (
<select id={id} value={value} onChange={onSelectChange} disabled={disabled}>
{options}
</select>
);
};
export default CurrencySelect;

View File

@ -1,94 +0,0 @@
@import "../../styles/colors.scss";
.container {
position: relative;
display: flex;
flex-direction: row;
width: 100%;
// Immediate buttons in the container: the default and the dropdown
> button {
&:nth-child(1) {
flex-grow: 1;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right-width: 0;
}
&:nth-child(2) {
flex-grow: 0;
padding: 1rem 1.2rem;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
svg {
position: relative;
top: 3px;
}
}
}
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
margin-top: -1px;
display: flex;
flex-direction: column;
background-color: white;
border: 1px solid $color-light-grey;
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
button {
background-color: transparent;
text-align: left;
color: $color-body-text;
border: none;
border-radius: 0;
&:hover {
background-color: $color-light-grey;
}
&:disabled {
color: $color-light-grey;
&:hover {
background-color: transparent;
}
}
}
}
@media (prefers-color-scheme: dark) {
.dropdown {
background-color: lighten($dark-mode-background, 5%);
border-color: $color-mid-grey;
button {
color: $dark-mode-body-text;
&:hover {
background-color: $color-mid-grey;
}
&:disabled {
color: $color-mid-grey;
}
}
}
}

View File

@ -1,60 +0,0 @@
import React, { FC, useRef, useState } from "react";
import cn from "classnames";
import styles from "./DropdownButton.module.scss";
import Caret from "../icons/Caret";
import Dismissable from "../Dismissable";
export interface DropdownButtonProps {
title: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
}
export const DropdownButton: FC<
React.PropsWithChildren<DropdownButtonProps>
> = ({ title, onClick, disabled, children }) => {
const [open, setOpen] = useState(false);
const toggleRef = useRef<HTMLButtonElement>(null);
const onToggleClick: React.MouseEventHandler<HTMLButtonElement> = () => {
setOpen(!open);
};
const onDismiss = (event?: MouseEvent) => {
if (
event &&
toggleRef.current &&
toggleRef.current.contains(event.target as Node)
) {
// This is a dismiss click on the actual toggle button, so we don't need to dismiss
return;
}
setOpen(false);
};
return (
<div className={styles.container}>
<button type="button" onClick={onClick} disabled={disabled}>
{title}
</button>
<button
ref={toggleRef}
type="button"
onClick={onToggleClick}
disabled={disabled}
>
<Caret direction="down" filled />
</button>
{open && (
<Dismissable onDismiss={onDismiss}>
<div className={styles.dropdown} onClick={() => onDismiss()}>
{children}
</div>
</Dismissable>
)}
</div>
);
};
export default DropdownButton;

View File

@ -1,3 +0,0 @@
.numberInput {
text-align: right;
}

View File

@ -1,102 +0,0 @@
import React, { FC, useRef, useState } from "react";
import cn from "classnames";
import { formatNumber } from "../../lib/utils";
import styles from "./NumberInput.module.scss";
export interface NumberInputProps {
value: number;
places?: number;
prefix?: string;
suffix?: string;
onChange?: (value: number) => void;
className?: string;
id?: string;
disabled?: boolean;
}
const NumberInput: FC<NumberInputProps> = ({
value,
places = 2,
prefix,
suffix,
onChange,
className,
id,
disabled,
}) => {
const [focused, setFocused] = useState(false);
const [editValue, setEditValue] = useState(value.toFixed(places));
const inputEl = useRef<HTMLInputElement>(null);
var elementValue = editValue;
if (!focused) {
elementValue = formatNumber(value, places, prefix, suffix);
const valString = value.toFixed(places);
if (editValue != valString) {
setEditValue(valString);
}
}
const onFocus = () => {
window.setTimeout(() => {
if (inputEl.current) {
inputEl.current.select();
}
}, 150);
setFocused(true);
};
const onBlur = () => {
setFocused(false);
try {
var valueNum = parseFloat(editValue);
if (onChange) {
onChange(valueNum);
}
} catch (exc) {
console.error(exc);
setEditValue(value.toFixed(places));
}
};
const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setEditValue(event.target.value);
};
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === "Enter") {
try {
var valueNum = parseFloat(editValue);
if (onChange) {
onChange(valueNum);
if (inputEl.current) {
inputEl.current.select();
}
}
} catch {}
}
};
return (
<input
ref={inputEl}
type="text"
inputMode="decimal"
className={cn(styles.numberInput, className)}
id={id}
disabled={disabled}
value={elementValue}
onFocus={onFocus}
onBlur={onBlur}
onChange={onInputChange}
onKeyDown={onKeyDown}
/>
);
};
export default NumberInput;

View File

@ -1,41 +0,0 @@
@import "../../styles/colors.scss";
.button {
position: fixed;
z-index: 50;
bottom: 2em;
right: 2em;
opacity: 0;
transform: translateY(100px);
&.visible {
opacity: 1;
transform: translateY(0);
}
&.skipFooter {
transform: translateY(-10px);
}
}
@media (max-width: 1170px) {
.button {
> span {
display: none;
}
}
}
@media (max-width: 800px) {
.button {
display: none;
}
}
@media print {
.button {
display: none;
}
}

View File

@ -1,64 +0,0 @@
import { FC, useEffect, useRef, useState } from "react";
import cn from "classnames";
import styles from "./ScrollToTop.module.scss";
export const ScrollToTopButton: FC = () => {
const footerRef = useRef<HTMLElement | null>(null);
const [visible, setVisible] = useState<boolean>(false);
const [footerVisible, setFooterVisible] = useState<boolean>(false);
const onFooterIntersection: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => {
switch (entry.target.tagName) {
case "NAV":
setVisible(!entry.isIntersecting);
break;
case "FOOTER":
setFooterVisible(entry.isIntersecting);
break;
}
});
};
useEffect(() => {
const header = document.querySelector("nav:first-of-type");
const footer = document.querySelector("footer");
let observer = new IntersectionObserver(onFooterIntersection);
if (header) {
observer.observe(header);
}
if (footer) {
observer.observe(footer);
footerRef.current = footer;
}
return () => {
observer.disconnect();
};
}, []);
return (
<button
className={cn(styles.button, "scroll-button", {
[styles.visible]: visible,
[styles.skipFooter]: footerVisible,
})}
style={{
transform:
visible && footerVisible && footerRef.current
? `translateY(-${
footerRef.current.getBoundingClientRect().height
}px)`
: undefined,
}}
tabIndex={-1}
onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}}
>
&uarr;<span> Goto Top</span>
</button>
);
};

View File

@ -1,62 +0,0 @@
@import "../../styles/colors.scss";
.toggle {
display: block;
position: relative;
cursor: pointer;
width: 6rem;
height: 3rem;
&.toggleActive {
.toggleBackground {
background-color: lighten($primary-background, 20%);
}
.toggleInner {
right: 0rem;
}
}
}
.toggleBackground {
position: absolute;
width: 6rem;
height: 1.5rem;
top: 0.75rem;
border-radius: 1.5rem;
background-color: $color-light-grey;
}
.toggleInner {
position: absolute;
width: 3rem;
height: 3rem;
top: 0;
right: 3rem;
border-radius: 1.5rem;
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
transition: right 0.125s ease-in-out;
}
@media (prefers-color-scheme: dark) {
.toggleBackground {
background-color: $color-mid-grey;
}
.toggleInner {
background-color: lighten($primary-background, 10%);
}
}

View File

@ -1,36 +0,0 @@
import React, { FC } from "react";
import cn from "classnames";
import styles from "./Toggle.module.scss";
export interface ToggleProps {
value: boolean;
disabled?: boolean;
onChange: (value: boolean) => void;
style?: React.CSSProperties;
}
export const Toggle: FC<ToggleProps> = ({
value,
disabled = false,
onChange,
style,
}) => {
return (
<div
className={cn(styles.toggle, {
[styles.toggleActive]: value,
[styles.toggleDisabled]: disabled,
})}
onClick={(event) => {
event.stopPropagation();
onChange(!value);
}}
style={style}
>
<div className={styles.toggleBackground} />
<div className={styles.toggleInner} />
</div>
);
};
export default Toggle;

View File

@ -1,126 +0,0 @@
import React, { FC } from "react";
import { Direction } from "../../lib/types";
const ARROWS: { [direction: string]: React.ReactElement<any, any>[] } = {
down: [
<svg
key="down-short"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M8 4a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 10.293V4.5A.5.5 0 0 1 8 4z"
/>
</svg>,
<svg
key="down-long"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"
/>
</svg>,
],
left: [
<svg
key="left-short"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M12 8a.5.5 0 0 1-.5.5H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H11.5a.5.5 0 0 1 .5.5z"
/>
</svg>,
<svg
key="left-long"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"
/>
</svg>,
],
up: [
<svg
key="up-short"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M8 12a.5.5 0 0 0 .5-.5V5.707l2.146 2.147a.5.5 0 0 0 .708-.708l-3-3a.5.5 0 0 0-.708 0l-3 3a.5.5 0 1 0 .708.708L7.5 5.707V11.5a.5.5 0 0 0 .5.5z"
/>
</svg>,
<svg
key="up-long"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"
/>
</svg>,
],
right: [
<svg
key="right-short"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"
/>
</svg>,
<svg
key="right-long"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"
/>
</svg>,
],
};
export const Arrow: FC<{ direction: Direction; short?: boolean }> = ({
direction,
short = false,
}) => {
return ARROWS[direction][short ? 0 : 1];
};
export default Arrow;

View File

@ -1,102 +0,0 @@
import React, { FC } from "react";
import { Direction } from "../../lib/types";
const CARETS: { [direction: string]: React.ReactElement<any, any>[] } = {
up: [
<svg
key="up-filled"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M3.204 11h9.592L8 5.519 3.204 11zm-.753-.659 4.796-5.48a1 1 0 0 1 1.506 0l4.796 5.48c.566.647.106 1.659-.753 1.659H3.204a1 1 0 0 1-.753-1.659z" />
</svg>,
<svg
key="up"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="m7.247 4.86-4.796 5.481c-.566.647-.106 1.659.753 1.659h9.592a1 1 0 0 0 .753-1.659l-4.796-5.48a1 1 0 0 0-1.506 0z" />
</svg>,
],
down: [
<svg
key="down-filled"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M3.204 5h9.592L8 10.481 3.204 5zm-.753.659 4.796 5.48a1 1 0 0 0 1.506 0l4.796-5.48c.566-.647.106-1.659-.753-1.659H3.204a1 1 0 0 0-.753 1.659z" />
</svg>,
<svg
key="down"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z" />
</svg>,
],
left: [
<svg
key="left-filled"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M10 12.796V3.204L4.519 8 10 12.796zm-.659.753-5.48-4.796a1 1 0 0 1 0-1.506l5.48-4.796A1 1 0 0 1 11 3.204v9.592a1 1 0 0 1-1.659.753z" />
</svg>,
<svg
key="left"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="m3.86 8.753 5.482 4.796c.646.566 1.658.106 1.658-.753V3.204a1 1 0 0 0-1.659-.753l-5.48 4.796a1 1 0 0 0 0 1.506z" />
</svg>,
],
right: [
<svg
key="right-filled"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M6 12.796V3.204L11.481 8 6 12.796zm.659.753 5.48-4.796a1 1 0 0 0 0-1.506L6.66 2.451C6.011 1.885 5 2.345 5 3.204v9.592a1 1 0 0 0 1.659.753z" />
</svg>,
<svg
key="right"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z" />
</svg>,
],
};
export const Caret: FC<{
direction: Direction;
filled?: boolean;
}> = ({ direction, filled = false }) => {
return CARETS[direction][filled ? 1 : 0];
};
export default Caret;

View File

@ -1,12 +0,0 @@
import React, { FC } from "react";
export const DevTo: FC = () => {
return (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>dev.to</title>
<path d="M7.42 10.05c-.18-.16-.46-.23-.84-.23H6l.02 2.44.04 2.45.56-.02c.41 0 .63-.07.83-.26.24-.24.26-.36.26-2.2 0-1.91-.02-1.96-.29-2.18zM0 4.94v14.12h24V4.94H0zM8.56 15.3c-.44.58-1.06.77-2.53.77H4.71V8.53h1.4c1.67 0 2.16.18 2.6.9.27.43.29.6.32 2.57.05 2.23-.02 2.73-.47 3.3zm5.09-5.47h-2.47v1.77h1.52v1.28l-.72.04-.75.03v1.77l1.22.03 1.2.04v1.28h-1.6c-1.53 0-1.6-.01-1.87-.3l-.3-.28v-3.16c0-3.02.01-3.18.25-3.48.23-.31.25-.31 1.88-.31h1.64v1.3zm4.68 5.45c-.17.43-.64.79-1 .79-.18 0-.45-.15-.67-.39-.32-.32-.45-.63-.82-2.08l-.9-3.39-.45-1.67h.76c.4 0 .75.02.75.05 0 .06 1.16 4.54 1.26 4.83.04.15.32-.7.73-2.3l.66-2.52.74-.04c.4-.02.73 0 .73.04 0 .14-1.67 6.38-1.8 6.68z" />
</svg>
);
};
export default DevTo;

View File

@ -1,11 +0,0 @@
import React, { FC } from "react";
export const GitHub: FC = () => {
return (
<svg viewBox="0 0 32 32">
<path d="M16 .395c-8.836 0-16 7.163-16 16 0 7.069 4.585 13.067 10.942 15.182.8.148 1.094-.347 1.094-.77 0-.381-.015-1.642-.022-2.979-4.452.968-5.391-1.888-5.391-1.888-.728-1.849-1.776-2.341-1.776-2.341-1.452-.993.11-.973.11-.973 1.606.113 2.452 1.649 2.452 1.649 1.427 2.446 3.743 1.739 4.656 1.33.143-1.034.558-1.74 1.016-2.14-3.554-.404-7.29-1.777-7.29-7.907 0-1.747.625-3.174 1.649-4.295-.166-.403-.714-2.03.155-4.234 0 0 1.344-.43 4.401 1.64a15.353 15.353 0 0 1 4.005-.539c1.359.006 2.729.184 4.008.539 3.054-2.07 4.395-1.64 4.395-1.64.871 2.204.323 3.831.157 4.234 1.026 1.12 1.647 2.548 1.647 4.295 0 6.145-3.743 7.498-7.306 7.895.574.497 1.085 1.47 1.085 2.963 0 2.141-.019 3.864-.019 4.391 0 .426.288.925 1.099.768C27.421 29.457 32 23.462 32 16.395c0-8.837-7.164-16-16-16z" />
</svg>
);
};
export default GitHub;

View File

@ -1,9 +0,0 @@
import React, { FC } from "react";
export const Mastodon: FC = () => {
return (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Mastodon</title><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
)
};
export default Mastodon;

View File

@ -1,17 +0,0 @@
import React, { FC } from "react";
export const QuestionCircle: FC = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247zm2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z" />
</svg>
);
};
export default QuestionCircle;

View File

@ -1,11 +0,0 @@
import React, { FC } from "react";
export const Rss: FC = () => {
return (
<svg viewBox="0 0 16 16">
<path d="M5.5 12a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-3-8.5a1 1 0 0 1 1-1c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1-1-1zm0 4a1 1 0 0 1 1-1 6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1-1-1z" />
</svg>
);
};
export default Rss;

View File

@ -1,11 +0,0 @@
import React, { FC } from "react";
export const Search: FC = () => {
return (
<svg viewBox="0 0 32 32">
<path d="M 12 0 A 12 12 0 0 0 0 12 A 12 12 0 0 0 12 24 A 12 12 0 0 0 18.753906 21.917969 C 18.887375 22.146246 19.042704 22.366923 19.242188 22.566406 L 27.779297 31.103516 C 28.854914 32.179133 30.462126 32.303499 31.382812 31.382812 C 32.303499 30.462126 32.179133 28.854914 31.103516 27.779297 L 22.566406 19.242188 C 22.364055 19.039836 22.140067 18.882462 21.908203 18.748047 A 12 12 0 0 0 24 12 A 12 12 0 0 0 12 0 z M 12 3.4570312 A 8.5423727 8.5423727 0 0 1 20.542969 12 A 8.5423727 8.5423727 0 0 1 12 20.542969 A 8.5423727 8.5423727 0 0 1 3.4570312 12 A 8.5423727 8.5423727 0 0 1 12 3.4570312 z" />
</svg>
);
};
export default Search;

View File

@ -1,61 +0,0 @@
import React, { FC, useEffect } from "react";
import {
AccountAction,
AccountInfo,
accountReducer,
loadAccount,
} from "../../../lib/tools/position-size/account";
import { getExchangeRates } from "../../../lib/tools/position-size/forex";
export interface AccountContextProps {
account: AccountInfo;
dispatch: React.Dispatch<AccountAction>;
}
export const AccountContext = React.createContext<
AccountContextProps | undefined
>(undefined);
function load(): AccountInfo {
const res = loadAccount();
if (res === null) {
return {
places: 4,
currency: "GBP",
exchangeRates: {
base: "GBP",
rates: new Map(),
},
amount: 500,
marginRisk: 0.01,
positionRisk: 0.01,
};
}
return res;
}
export const AccountProvider: FC<React.PropsWithChildren> = ({ children }) => {
const [account, dispatch] = React.useReducer(accountReducer, load());
useEffect(() => {
getExchangeRates(account.currency).then((exchangeRates) => {
dispatch({ action: "setExchangeRates", exchangeRates });
});
}, [account.currency]);
return (
<AccountContext.Provider value={{ account, dispatch }}>
{children}
</AccountContext.Provider>
);
};
export function useAccount(): AccountContextProps {
const context = React.useContext(AccountContext);
if (!context) {
throw new Error("useAccount must be used within an AccountProvider");
}
return context;
}

View File

@ -1,56 +0,0 @@
import React, { FC, useEffect } from "react";
import { getExchangeRates } from "../../../lib/tools/position-size/forex";
import {
PositionAction,
PositionInfo,
positionReducer,
} from "../../../lib/tools/position-size/position";
export interface PositionContextProps {
position: PositionInfo;
dispatch: React.Dispatch<PositionAction>;
}
export const PositionContext = React.createContext<
PositionContextProps | undefined
>(undefined);
export const PositionProvider: FC<React.PropsWithChildren> = ({ children }) => {
const [position, dispatch] = React.useReducer(positionReducer, {
posCurrency: "GBP",
quoteCurrency: "GBP",
conversion: 1,
openPrice: 0,
quantity: null,
direction: "buy",
margin: 0.05,
takeProfit: null,
stopLoss: null,
});
useEffect(() => {
getExchangeRates(position.posCurrency, position.quoteCurrency).then(
(exchangeRates) => {
dispatch({
action: "setConversion",
conversion: exchangeRates.rates.get(position.quoteCurrency) || 1,
});
}
);
}, [position.posCurrency, position.quoteCurrency]);
return (
<PositionContext.Provider value={{ position, dispatch }}>
{children}
</PositionContext.Provider>
);
};
export function usePosition(): PositionContextProps {
const context = React.useContext(PositionContext);
if (!context) {
throw new Error("usePosition must be used within a PositionProvider");
}
return context;
}

View File

@ -1,10 +0,0 @@
.riskGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 800px) {
.riskGrid {
grid-template-columns: 1fr;
row-gap: 2rem;
}
}

View File

@ -1,82 +0,0 @@
import React, { FC } from "react";
import { Currency, CURRENCY_SYMBOLS } from "../../../../lib/tools/position-size/forex";
import Card from "../../../display/Card";
import CurrencySelect from "../../../fields/CurrencySelect";
import FloatingLabel from "../../../display/FloatingLabel";
import Grid from "../../../display/Grid";
import NumberInput from "../../../fields/NumberInput";
import { useAccount } from "../AccountProvider";
import styles from "./AccountInfo.module.scss";
export const AccountInfoPanel: FC = () => {
const { account, dispatch } = useAccount();
const onCurrencyChange = (currency: Currency) => {
dispatch({ action: "setCurrency", currency });
};
const onAmountChange = (amount: number) => {
dispatch({ action: "setAmount", amount });
};
const onMarginRiskChange = (risk: number) => {
dispatch({ action: "setMarginRisk", risk: risk / 100 });
};
const onPositionRiskChange = (risk: number) => {
dispatch({ action: "setPositionRisk", risk: risk / 100 });
};
const onPlacesChange = (places: number) => {
dispatch({ action: "setPlaces", places });
};
return (
<Card title="Account Information">
<Grid rowGap={2}>
<FloatingLabel title="Account Currency">
<CurrencySelect
value={account.currency}
onChange={onCurrencyChange}
/>
</FloatingLabel>
<FloatingLabel title="Account Value">
<NumberInput
value={account.amount}
prefix={CURRENCY_SYMBOLS.get(account.currency)}
places={account.places}
onChange={onAmountChange}
/>
</FloatingLabel>
<Grid className={styles.riskGrid} columnGap={2}>
<FloatingLabel title="Margin Risk">
<NumberInput
value={account.marginRisk * 100}
places={0}
suffix="%"
onChange={onMarginRiskChange}
/>
</FloatingLabel>
<FloatingLabel title="Position Risk">
<NumberInput
value={account.positionRisk * 100}
places={0}
suffix="%"
onChange={onPositionRiskChange}
/>
</FloatingLabel>
</Grid>
<FloatingLabel title="Decimal Places">
<NumberInput
value={account.places}
places={0}
suffix=" digits"
onChange={onPlacesChange}
/>
</FloatingLabel>
</Grid>
</Card>
);
};
export default AccountInfoPanel;

View File

@ -1,22 +0,0 @@
.positionGrid,
.quantityGrid,
.takeStopGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.priceGrid {
grid-template-columns: 1fr;
}
@media (max-width: 1170px) {
.positionGrid,
.quantityGrid,
.takeStopGrid {
grid-template-columns: 1fr;
}
.positionGrid,
.quantityGrid,
.takeStopGrid {
row-gap: 2rem;
}
}

View File

@ -1,319 +0,0 @@
import React, { FC } from "react";
import { Currency, CURRENCY_SYMBOLS } from "../../../../lib/tools/position-size/forex";
import {
computedStopLossQuantity,
computePositionSize,
Direction,
} from "../../../../lib/tools/position-size/position";
import { formatNumber } from "../../../../lib/utils";
import Card from "../../../display/Card";
import CurrencySelect from "../../../fields/CurrencySelect";
import DropdownButton from "../../../fields/DropdownButton";
import FloatingLabel from "../../../display/FloatingLabel";
import Grid from "../../../display/Grid";
import NumberInput from "../../../fields/NumberInput";
import Toggle from "../../../fields/Toggle";
import { useAccount } from "../AccountProvider";
import { usePosition } from "../PositionProvider";
import styles from "./PositionInfo.module.scss";
export const PositionInfoPanel: FC = () => {
const { account } = useAccount();
const { position, dispatch } = usePosition();
const leverage =
position.margin !== 0 ? (
<small>{`(${Math.round(1 / position.margin)}x leverage)`}</small>
) : null;
// const takeProfitDistance =
// typeof position.takeProfit === "number"
// ? position.direction === "buy"
// ? position.takeProfit - position.openPrice
// : position.openPrice - position.takeProfit
// : 0;
const stopLossDistance =
typeof position.stopLoss === "number"
? position.direction === "buy"
? position.openPrice - position.stopLoss
: position.stopLoss - position.openPrice
: 0;
const onPosCurrencyChange = (currency: Currency) => {
dispatch({ action: "setPosCurrency", currency });
};
const onQuoteCurrencyChange = (currency: Currency) => {
dispatch({ action: "setQuoteCurrency", currency });
};
const onMarginChange = (margin: number) => {
dispatch({ action: "setMargin", margin: margin / 100 });
};
const onOpenPriceChange = (openPrice: number) => {
dispatch({ action: "setOpenPrice", openPrice });
};
const onQuantityToggleChange = (enabled: boolean) => {
if (enabled) {
dispatch({ action: "setQuantity", quantity: 0 });
} else {
dispatch({ action: "setQuantity", quantity: null });
}
};
const onQuantityChange = (quantity: number) => {
dispatch({ action: "setQuantity", quantity });
};
const onUseAffordableClick = () => {
console.log("useAffordableClick");
const { quantity } = computePositionSize(account, position);
dispatch({ action: "setQuantity", quantity });
};
const onUseAvailableClick = () => {
const { quantity } = computedStopLossQuantity(account, position);
dispatch({ action: "setQuantity", quantity });
};
const onDirectionChange: React.ChangeEventHandler<HTMLSelectElement> = (
event
) => {
dispatch({
action: "setDirection",
direction: event.target.value as Direction,
});
};
// const onTakeProfitToggleChange = (enabled: boolean) => {
// if (enabled) {
// dispatch({ action: "setTakeProfit", takeProfit: position.openPrice });
// } else {
// dispatch({ action: "setTakeProfit", takeProfit: null });
// }
// };
//
// const onTakeProfitChange = (takeProfit: number) => {
// dispatch({ action: "setTakeProfit", takeProfit });
// };
//
// const onTakeProfitDistanceChange = (takeProfitDistance: number) => {
// dispatch({
// action: "setTakeProfit",
// takeProfit:
// position.direction === "buy"
// ? position.openPrice + takeProfitDistance
// : position.openPrice - takeProfitDistance,
// });
// };
const onStopLossToggleChange = (enabled: boolean) => {
if (enabled) {
dispatch({ action: "setStopLoss", stopLoss: position.openPrice });
} else {
dispatch({ action: "setStopLoss", stopLoss: null });
}
};
const onStopLossChange = (stopLoss: number) => {
dispatch({ action: "setStopLoss", stopLoss });
};
const onStopLossDistanceChange = (stopLossDistance: number) => {
dispatch({
action: "setStopLoss",
stopLoss:
position.direction === "buy"
? position.openPrice - stopLossDistance
: position.openPrice + stopLossDistance,
});
};
const positionSymbol = CURRENCY_SYMBOLS.get(position.posCurrency);
const quoteSymbol = CURRENCY_SYMBOLS.get(position.quoteCurrency);
const posExchange =
account.currency !== position.posCurrency ? (
<>
({account.currency}&rarr;{position.posCurrency}{" "}
{formatNumber(
account.exchangeRates.rates.get(position.posCurrency) || 0,
account.places,
positionSymbol
)}
)
</>
) : (
<>&nbsp;</>
);
const quoteExchange =
position.quoteCurrency !== position.posCurrency ? (
<>
({position.posCurrency}&rarr;{position.quoteCurrency}{" "}
{formatNumber(position.conversion, account.places, quoteSymbol)})
</>
) : (
<>&nbsp;</>
);
return (
<Card title="Position Information">
<Grid rowGap={2}>
<Grid className={styles.positionGrid} columnGap={2}>
<FloatingLabel
title={
<span>
Position <small>{posExchange}</small>
</span>
}
>
<CurrencySelect
value={position.posCurrency}
onChange={onPosCurrencyChange}
/>
</FloatingLabel>
<FloatingLabel
title={
<span>
Quote <small>{quoteExchange}</small>
</span>
}
>
<CurrencySelect
value={position.quoteCurrency}
onChange={onQuoteCurrencyChange}
/>
</FloatingLabel>
</Grid>
<Grid className={styles.positionGrid} columnGap={2}>
<FloatingLabel title={<span>Position Margin {leverage}</span>}>
<NumberInput
value={position.margin * 100}
places={2}
suffix="%"
onChange={onMarginChange}
/>
</FloatingLabel>
<FloatingLabel title="Position Direction">
<select value={position.direction} onChange={onDirectionChange}>
<option value="buy">Buy</option>
<option value="sell">Sell</option>
</select>
</FloatingLabel>
</Grid>
<Grid className={styles.priceGrid}>
<FloatingLabel title="Open Price">
<NumberInput
value={position.openPrice}
places={account.places}
prefix={quoteSymbol}
onChange={onOpenPriceChange}
/>
</FloatingLabel>
</Grid>
<Grid columns={["6rem", "1fr"]} columnGap={2}>
<Toggle
value={typeof position.quantity === "number"}
onChange={onQuantityToggleChange}
style={{ marginTop: "3rem" }}
/>
<Grid className={styles.quantityGrid} columnGap={2}>
<FloatingLabel title="Quantity">
<NumberInput
value={position.quantity || 0}
places={2}
suffix=" units"
onChange={onQuantityChange}
disabled={typeof position.quantity !== "number"}
/>
</FloatingLabel>
<FloatingLabel title="Use Calculated Value">
<DropdownButton
title="Affordable"
onClick={onUseAffordableClick}
disabled={
typeof position.quantity !== "number" ||
position.openPrice === 0
}
>
<button type="button" onClick={onUseAffordableClick}>
Affordable Quantity
</button>
<button
type="button"
onClick={onUseAvailableClick}
disabled={
typeof position.stopLoss !== "number" ||
stopLossDistance === 0
}
>
Stop Loss Quantity
</button>
</DropdownButton>
</FloatingLabel>
</Grid>
</Grid>
{/*
<Grid columns={["6rem", "1fr"]} columnGap={2}>
<Toggle
value={typeof position.takeProfit === "number"}
onChange={onTakeProfitToggleChange}
style={{ marginTop: "3rem" }}
/>
<Grid className={styles.takeStopGrid} columnGap={2}>
<FloatingLabel title="Take Profit">
<NumberInput
value={position.takeProfit || 0}
places={account.places}
prefix={positionSymbol}
onChange={onTakeProfitChange}
disabled={typeof position.takeProfit !== "number"}
/>
</FloatingLabel>
<FloatingLabel title="Take Profit Distance">
<NumberInput
value={takeProfitDistance}
places={account.places}
prefix={positionSymbol}
onChange={onTakeProfitDistanceChange}
disabled={typeof position.takeProfit !== "number"}
/>
</FloatingLabel>
</Grid>
</Grid>
*/}
<Grid columns={["6rem", "1fr"]} columnGap={2}>
<Toggle
value={typeof position.stopLoss === "number"}
onChange={onStopLossToggleChange}
style={{ marginTop: "3rem" }}
/>
<Grid className={styles.takeStopGrid} columnGap={2}>
<FloatingLabel title="Stop Loss">
<NumberInput
value={position.stopLoss || 0}
places={account.places}
prefix={quoteSymbol}
onChange={onStopLossChange}
disabled={typeof position.stopLoss !== "number"}
/>
</FloatingLabel>
<FloatingLabel title="Stop Loss Distance">
<NumberInput
value={stopLossDistance}
places={account.places}
prefix={quoteSymbol}
onChange={onStopLossDistanceChange}
disabled={typeof position.stopLoss !== "number"}
/>
</FloatingLabel>
</Grid>
</Grid>
</Grid>
</Card>
);
};

View File

@ -1,30 +0,0 @@
.resultTable {
width: 100%;
tbody {
th {
text-align: left;
vertical-align: top;
}
}
}
.numberCell {
text-align: right;
}
.gridNoLoss {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.gridWithLoss {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (max-width: 1170px) {
.gridNoLoss,
.gridWithLoss {
grid-template-columns: 1fr;
grid-row-gap: 2rem;
}
}

View File

@ -1,724 +0,0 @@
import React, { FC } from "react";
import cn from "classnames";
import { CURRENCY_SYMBOLS } from "../../../../lib/tools/position-size/forex";
import {
computedStopLossQuantity,
computePositionSize,
computeStopLoss,
} from "../../../../lib/tools/position-size/position";
import { formatNumber } from "../../../../lib/utils";
import Card from "../../../display/Card";
import Grid from "../../../display/Grid";
import Tooltip from "../../../display/Tooltip";
import { useAccount } from "../AccountProvider";
import { usePosition } from "../PositionProvider";
import styles from "./PositionSizePanel.module.scss";
const SimplePositionSize: FC = () => {
const { account } = useAccount();
const { position } = usePosition();
const {
available,
availablePos,
availableQuote,
margin,
marginPos,
marginQuote,
quantity,
actual,
} = computePositionSize(account, position);
return (
<Card title="Simple Position Size">
<table className={styles.resultTable}>
<tbody>
<tr>
<th
rowSpan={
1 +
(account.currency !== position.posCurrency ? 1 : 0) +
(position.posCurrency !== position.quoteCurrency ? 1 : 0)
}
>
Available Account
</th>
<td className={styles.numberCell}>
{formatNumber(
available,
account.places,
CURRENCY_SYMBOLS.get(account.currency)
)}
<Tooltip position="left">
Amount of account available under margin risk
</Tooltip>
</td>
</tr>
{account.currency !== position.posCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
availablePos,
account.places,
CURRENCY_SYMBOLS.get(position.posCurrency)
)}
<Tooltip position="left">
Available account under margin risk in the position currency
</Tooltip>
</td>
</tr>
)}
{position.posCurrency !== position.quoteCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
availableQuote,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
Available account under margin risk in the quote currency
</Tooltip>
</td>
</tr>
)}
<tr>
<th
rowSpan={
1 +
(account.currency !== position.posCurrency ? 1 : 0) +
(position.posCurrency !== position.quoteCurrency ? 1 : 0)
}
>
Available Margin
</th>
<td className={styles.numberCell}>
{formatNumber(
margin,
account.places,
CURRENCY_SYMBOLS.get(account.currency)
)}
<Tooltip position="left">
Available amount with a{" "}
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
position margin
</Tooltip>
</td>
</tr>
{account.currency !== position.posCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
marginPos,
account.places,
CURRENCY_SYMBOLS.get(position.posCurrency)
)}
<Tooltip position="left">
Available amount with a{" "}
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
position margin converted to position currency
</Tooltip>
</td>
</tr>
)}
{position.posCurrency !== position.quoteCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
marginQuote,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
Available amount with a{" "}
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
position margin converted to quote currency
</Tooltip>
</td>
</tr>
)}
<tr>
<th>Affordable Quantity</th>
<td className={styles.numberCell}>
{position.openPrice !== 0 ? (
<b>{formatNumber(quantity, 2, undefined, " units")}</b>
) : (
"-"
)}
<Tooltip position="left">
Position size that can be taken at an open price of{" "}
{formatNumber(
position.openPrice,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}{" "}
with available margin of{" "}
{formatNumber(
marginQuote,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
</Tooltip>
</td>
</tr>
{actual && (position.quantity || 0) > 0 && (
<>
<tr>
<td>&nbsp;</td>
<td />
</tr>
<tr>
<th>Actual Quantity</th>
<td className={styles.numberCell}>
{formatNumber(position.quantity || 0, 2, undefined, " units")}
<Tooltip position="left">
Quantity entered into position form
</Tooltip>
</td>
</tr>
<tr>
<th>Actual Cost</th>
<td className={styles.numberCell}>
{formatNumber(
(position.quantity || 0) * position.openPrice,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
Cost of opening the position of{" "}
{formatNumber(position.quantity || 0, 2)} units at{" "}
{formatNumber(
position.openPrice,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
</Tooltip>
</td>
</tr>
<tr>
<th
rowSpan={
2 +
(account.currency !== position.posCurrency ? 1 : 0) +
(position.posCurrency !== position.quoteCurrency ? 1 : 0)
}
>
Required Margin
</th>
<td className={styles.numberCell}>
{formatNumber(
actual.costQuote,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
Amount required at{" "}
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
position margin (
{formatNumber(
1.0 / (position.margin || 1),
0,
undefined,
"x"
)}{" "}
leverage)
</Tooltip>
</td>
</tr>
{position.quoteCurrency !== position.posCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
actual.costPos,
account.places,
CURRENCY_SYMBOLS.get(position.posCurrency)
)}
<Tooltip position="left">
Amount required at{" "}
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
margin, converted into the position currency.
</Tooltip>
</td>
</tr>
)}
{account.currency !== position.posCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
actual.cost,
account.places,
CURRENCY_SYMBOLS.get(account.currency)
)}
<Tooltip position="left">
Amount required at{" "}
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
margin, converted into the account currency.
</Tooltip>
</td>
</tr>
)}
<tr>
<td
className={cn(styles.numberCell, {
"text-danger":
Math.round(100 * actual.margin) >
100 * account.marginRisk,
})}
>
{formatNumber(actual.margin * 100, 2, undefined, "%")}
<Tooltip position="left">
The percentage of the account that will be committed as
margin to open the position
</Tooltip>
</td>
</tr>
{Math.round(100 * actual.margin) > 100 * account.marginRisk && (
<tr>
<td
colSpan={2}
className="text-danger"
style={{ paddingTop: "2rem" }}
>
Actual quantity of {formatNumber(position.quantity || 0, 2)}{" "}
units exceeds account margin risk of{" "}
{formatNumber(account.marginRisk * 100, 0, undefined, "%")}{" "}
by{" "}
{formatNumber(
actual.costPos - available,
2,
CURRENCY_SYMBOLS.get(account.currency)
)}
.
</td>
</tr>
)}
</>
)}
</tbody>
</table>
<p>
Given the account <i>Margin Risk</i>, what is the maximum possible
position size that can be opened at the given position <i>Open Price</i>
.
</p>
</Card>
);
};
const StopLossPosition: FC = () => {
const { account } = useAccount();
const { position } = usePosition();
const quantity =
typeof position.quantity === "number"
? position.quantity
: computePositionSize(account, position).quantity;
const { available, availablePos, availableQuote, distance, actual } =
computeStopLoss(account, position, quantity);
return (
<Card title="Stop Loss Position">
<table className={styles.resultTable}>
<tbody>
<tr>
<th
rowSpan={
1 +
(account.currency !== position.posCurrency ? 1 : 0) +
(position.quoteCurrency !== position.posCurrency ? 1 : 0)
}
>
Available Account
</th>
<td className={styles.numberCell}>
{formatNumber(
available,
account.places,
CURRENCY_SYMBOLS.get(account.currency)
)}
<Tooltip position="left">
Amount of account available under position risk of{" "}
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}
</Tooltip>
</td>
</tr>
{account.currency !== position.posCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
availablePos,
account.places,
CURRENCY_SYMBOLS.get(position.posCurrency)
)}
<Tooltip position="left">
Available account under position risk of{" "}
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
in the position currency
</Tooltip>
</td>
</tr>
)}
{position.quoteCurrency !== position.posCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
availableQuote,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
Available account under position risk of{" "}
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
in the quote currency
</Tooltip>
</td>
</tr>
)}
<tr>
<th>Stop Loss Distance</th>
<td className={styles.numberCell}>
{formatNumber(
distance,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
The maximum stop loss distance for a position of{" "}
{formatNumber(quantity, 2, undefined, " units")} at{" "}
{formatNumber(
position.openPrice,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}{" "}
to remain within the position risk of{" "}
{formatNumber(account.positionRisk * 100, 2, undefined, "%")} of
the account
</Tooltip>
</td>
</tr>
<tr>
<th>Stop Loss</th>
<td className={styles.numberCell}>
<b>
{formatNumber(
position.direction === "buy"
? position.openPrice - distance
: position.openPrice + distance,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
The maximum stop loss for a position of{" "}
{formatNumber(quantity, 2, undefined, " units")} at{" "}
{formatNumber(
position.openPrice,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}{" "}
to remain within the position risk of{" "}
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
of the account
</Tooltip>
</b>
</td>
</tr>
{actual && (
<>
<tr>
<td>&nbsp;</td>
<td />
</tr>
<tr>
<th>Actual Distance</th>
<td className={styles.numberCell}>
{formatNumber(
actual.distance,
account.places,
CURRENCY_SYMBOLS.get(position.posCurrency)
)}
<Tooltip position="left">
The distance provided in the position form
</Tooltip>
</td>
</tr>
<tr>
<th>Actual Loss</th>
<td className={styles.numberCell}>
{formatNumber(
actual.loss,
account.places,
CURRENCY_SYMBOLS.get(account.currency)
)}
<Tooltip position="left">
The actual account loss that will be incurred should the
position close at the provided stop loss position of{" "}
{formatNumber(
position.stopLoss || 0,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
</Tooltip>
</td>
</tr>
<tr>
<th>Actual Risk</th>
<td
className={cn(styles.numberCell, {
"text-danger":
Math.round(100 * actual.risk) >
100 * account.positionRisk,
})}
>
{formatNumber(actual.risk * 100, 2, undefined, "%")}
<Tooltip position="left">
Percentage of account at risk for the provided stop loss
position of{" "}
{formatNumber(
position.stopLoss || 0,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
</Tooltip>
</td>
</tr>
{Math.round(100 * actual.risk) > 100 * account.positionRisk && (
<tr>
<td
colSpan={2}
className="text-danger"
style={{ paddingTop: "2rem" }}
>
Actual stop loss of{" "}
{formatNumber(
actual.loss,
2,
CURRENCY_SYMBOLS.get(account.currency)
)}{" "}
exceeds account position risk of{" "}
{formatNumber(
account.positionRisk * 100,
0,
undefined,
"%"
)}{" "}
by{" "}
{formatNumber(
actual.loss - available,
2,
CURRENCY_SYMBOLS.get(account.currency)
)}
.
</td>
</tr>
)}
</>
)}
</tbody>
</table>
<p>
Given the{" "}
{typeof position.quantity === "number" ? "specified" : "simple"}{" "}
position size of <b>{formatNumber(quantity, 2)}</b> units, and the
account <i>Position Risk</i>, what is the maximum available stop loss.
</p>
</Card>
);
};
const PlannedStopLossQuantity: FC = () => {
const { account } = useAccount();
const { position } = usePosition();
const {
available,
availablePos,
availableQuote,
stopLossDistance,
quantity,
margin,
} = computedStopLossQuantity(account, position);
return (
<Card title="Planned Stop Loss Maximum">
<table className={styles.resultTable}>
<tbody>
<tr>
<th
rowSpan={
1 +
(account.currency !== position.posCurrency ? 1 : 0) +
(position.quoteCurrency !== position.posCurrency ? 1 : 0)
}
>
Available Account
</th>
<td className={styles.numberCell}>
{formatNumber(
available,
account.places,
CURRENCY_SYMBOLS.get(account.currency)
)}
<Tooltip position="left">
Amount of account available under position risk of{" "}
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}
</Tooltip>
</td>
</tr>
{account.currency !== position.posCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
availablePos,
account.places,
CURRENCY_SYMBOLS.get(position.posCurrency)
)}
<Tooltip position="left">
Available account under position risk of{" "}
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
in the position currency
</Tooltip>
</td>
</tr>
)}
{position.quoteCurrency !== position.posCurrency && (
<tr>
<td className={styles.numberCell}>
{formatNumber(
availableQuote,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
Available account under position risk of{" "}
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
in the quote currency
</Tooltip>
</td>
</tr>
)}
<tr>
<th>Stop Loss</th>
<td className={styles.numberCell}>
{formatNumber(
position.stopLoss || 0,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
Stop loss entered in position form.
</Tooltip>
</td>
</tr>
<tr>
<th>Stop Distance</th>
<td className={styles.numberCell}>
{formatNumber(
stopLossDistance,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
<Tooltip position="left">
Stop loss distance entered into position form.
</Tooltip>
</td>
</tr>
<tr>
<th>Available Quantity</th>
<td className={styles.numberCell}>
{stopLossDistance !== 0 ? (
<b>{formatNumber(quantity, 2)}</b>
) : (
"0"
)}
<Tooltip position="left">
The position size that can be taken at an open price of{" "}
{formatNumber(
position.openPrice,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}
, given an account position risk of{" "}
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}
</Tooltip>
</td>
</tr>
{stopLossDistance !== 0 && (
<>
<tr>
<th rowSpan={2}>Required Margin</th>
<td className={styles.numberCell}>
{formatNumber(
margin,
account.places,
CURRENCY_SYMBOLS.get(account.currency)
)}
<Tooltip position="left">
The amount of account margin that will be committed to
opening a position of{" "}
{formatNumber(quantity, 2, undefined, " units")} at{" "}
{formatNumber(
position.openPrice,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}{" "}
with a position margin of{" "}
{formatNumber(position.margin * 100, 2, undefined, "%")} (
{formatNumber(1 / position.margin, 0, undefined, "x")}{" "}
leverage)
</Tooltip>
</td>
</tr>
<tr>
<td className={styles.numberCell}>
{formatNumber(
(margin / account.amount) * 100,
2,
undefined,
"%"
)}
<Tooltip position="left">
The amount of account margin, as a percentage of the account
value, that will be committed to opening a position of{" "}
{formatNumber(quantity, 2, undefined, " units")} at{" "}
{formatNumber(
position.openPrice,
account.places,
CURRENCY_SYMBOLS.get(position.quoteCurrency)
)}{" "}
with a position margin of{" "}
{formatNumber(position.margin * 100, 2, undefined, "%")} (
{formatNumber(1 / position.margin, 0, undefined, "x")}{" "}
leverage)
</Tooltip>
</td>
</tr>
</>
)}
</tbody>
</table>
<p>
Given the entered position <i>Stop Loss</i> and the account{" "}
<i>Position Risk</i>, what is the maximum position size available.
</p>
</Card>
);
};
export const PositionSizePanel: FC = () => {
const { position } = usePosition();
return (
<Grid
className={
typeof position.stopLoss === "number"
? styles.gridWithLoss
: styles.gridNoLoss
}
columnGap={2}
mb={2}
>
<SimplePositionSize />
<StopLossPosition />
{typeof position.stopLoss === "number" && <PlannedStopLossQuantity />}
</Grid>
);
};
export default PositionSizePanel;

View File

@ -4,10 +4,10 @@ tags:
- ghost-tag
- javascript
- python
excerpt: I wanted to add search functionality to the Ghost CMS. In this post I
show the way that I achieved this.
published: 2019-11-02T08:30:00.000Z
cover: /content/photo-1546277838-f1e7a049a490.jpg
cover: /content/adding-search-to-ghost/cover.jpg
excerpt: |
I wanted to add search functionality to the Ghost CMS. In this post I show the way that I achieved this.
---
**tl;dr** I added search with a couple of Python scripts ([here](https://github.com/HalfWayMan/blakerain.com/blob/master/populate-db.py) and [here](https://github.com/HalfWayMan/blakerain.com/blob/master/simple-search.py)).
@ -30,7 +30,7 @@ So I decided to build a simple Python script that I can run as a cron job on the
To provide the actual search function, I decided that I'd add a simple Python web server that operating on a separate port to the main site (actually port [9443](https://blakerain.com:9443/search/not+a+search+term)), which provides an API that the client-side JavaScript can call to get search results.
### Populating the Database
# Populating the Database
The first step to developing the extractor script that would populate the search database was to get the shebang and imports out of the way. I knew that I wanted to have SQLite3, but I also needed the [requests](https://realpython.com/python-requests/) library to send HTTP requests to the Content API and the `os` module to allow me to pass the location of the database file and other settings as environment variables.
@ -139,7 +139,7 @@ With that out of the way I copied the Python script to the server, placing that
Confident that everything would magically work I moved on to the search API.
### Executing Search Queries
# Executing Search Queries
In order for the FTS3 table to be searched by some client-side JavaScript I decided to create another Python script that would use [Flask-RESTful](https://flask-restful.readthedocs.io/en/latest/) to provide an API. This API would accept a single search term, query the database, and then return any results as JSON. The client-side JavaScript could then use this JSON to render the search results.
@ -227,7 +227,7 @@ Oct 27 16:07:12 ip-?-?-? simple-search.py[11080]: 127.0.0.1 - - [27/Oct/2019 16:
Now that I new the API service was in place I needed to configure NGINX so that it would proxy HTTPS from port 9443 to the service port 5000. This meant adding a file in the directory `/etc/nginx/sites-available` that contained the configuration for NGINX. This file also needed to contain the links to the SSL certificate that [Let's Encrypt](https://letsencrypt.org) had set up when Ghost was being installed. Checking in `/etc/letsencrypt` showed a directory called `blakerain.com` that contain the certificate chain and the private key. I could use the default SSL settings from `/etc/nginx/snippets/ssl-params.conf` for the rest.
```nginx
```
server {
listen 9443 ssl http2;
listen [::]:9443 ssl http2;
@ -280,7 +280,7 @@ curl https://blakerain.com:9443/search/not+a+search
[]
```
### Client-Side Search
# Client-Side Search
Now that the back-end of the search seems to be working okay (although I've not seen it bring through any results yet), I started out on the client side. I knew that I wanted two things:
@ -373,7 +373,7 @@ A couple of things I will note, however:
1. Ghost lets you add some injection for specific pages, which is where I added some specific styling for the result HTML.
1. Be aware that if the search API doesn't specify an `Access-Control-Allow-Origin` then the web browser will refuse to make the request, even though the domain is actually the same.
### Conclusion
# Conclusion
In conclusion it seems that adding a separate search facility to Ghost was a lot easier than I was worried it might be. I had originally concerned myself with modifying Ghost itself (I've no idea what JAMstack is or how Ghost actually works). After seeing the other implementations I was inspired to take this approach, which seems to have worked quite well. The search is fairly fast, and will probably remain so for the foreseeable future.
@ -385,7 +385,7 @@ For now, you can find the Python scripts and the configuration files used on the
There you will find the sources such as `simple-search.py`.
### Future Improvements
## Future Improvements
There are a few things that I want to add to the search to improve it somewhat:

View File

@ -4,11 +4,11 @@ tags:
- pci
- linux
- cpp
excerpt: In this post we take a look at allocating memory on Linux using huge
pages with the intention of sharing that memory with PCIe devices that use
DMA.
published: 2020-12-11T18:30:00.000Z
cover: /content/harrison-broadbent-ING1Uf1Fc30-unsplash.jpg
cover: /content/allocating-memory-for-dma-in-linux/cover.jpg
excerpt: |
In this post we take a look at allocating memory on Linux using huge pages with the intention of
sharing that memory with PCIe devices that use DMA.
---
I've recently had the pleasure of writing some user-space code that takes control of an Ethernet card specifically the [Intel i350](https://ark.intel.com/content/www/us/en/ark/products/59062/intel-ethernet-server-adapter-i350-t2.html) and it's kin. Part of the interface with the device requires sharing memory that contains packet descriptors and buffers. The device uses this memory for the communication of transmitted and received Ethernet packets.
@ -17,7 +17,7 @@ When writing a user-space program that shares memory with a hardware device, we
To begin to understand this requires us to be notionally aware of the manner in which devices will access the memory that we share with them, and how to ask the OS to respect the physical location of the memory.
### How Devices Can Access Memory
# How Devices Can Access Memory
These days, devices that are connected to a computer are typically connected via PCI Express (usually abbreviate to PCIe). Such devices will typically include support for accessing memory via DMA (Direct Memory Access).
@ -31,7 +31,7 @@ In this more recent model of PCI, the [North Bridge](<https://en.wikipedia.org/w
When programming a device connected via PCIe, you will typically be writing a base address for a region of memory that you have prepared for the device to access. However, this memory cannot be allocated in the usual way. This is due to the way memory addresses are translated by the [MMU](https://en.wikipedia.org/wiki/Memory_management_unit) and the operating system the memory that we traditionally allocate from the operating system is _virtual_.
### Virtual and Physical Addresses
# Virtual and Physical Addresses {#virt-and-phy-addresses}
Typical memory allocation, such as when we use `malloc` or `new`, ultimately uses memory the operating system has reserved for our process. The address that we receive from the OS will be an address in the [virtual memory](https://en.wikipedia.org/wiki/Virtual_memory) maintained by the OS.
@ -48,7 +48,7 @@ It is important, therefore, that for any allocated memory we are able to obtain
In order to address these two primary concerns we need to look to an alternative means of memory allocation than the traditional `malloc` and `new`. Moreover, as we are likely to need more than a standard page's worth of space (typically 4Kib), we need to allocate memory using larger pages of memory.
### Establishing Physical Addresses
# Establishing Physical Addresses {#physical-addresses}
We understand that a process operates on virtual memory, and that memory is arranged in pages. The question now arises as to how we can establish the corresponding physical address for any given virtual address.
@ -130,7 +130,7 @@ static uintptr_t virtual_to_physical(const void *vaddr) {
We can now use the `virtual_to_physical` function to ascertain the physical address of some memory that we allocate from the operating system. This is the address that we pass on to our hardware.
### Linux Huge Pages
# Linux Huge Pages {#hugepages}
Now we know how to establish the physical address corresponding to a virtual address, the problem still remains that we need to obtain an address for _contiguous physical memory_, rather than merely the physical address of a single page. We are also still limited by the fact that the operating system may subject our memory to swapping and other operations.
@ -165,7 +165,7 @@ Something to note is that the kernel will try and balance the huge page pool ove
Huge pages provide a rather nice solution to our problem of obtaining large contiguous regions of memory that are not going to be swapped out by the operating system.
### Establishing Huge Page Availability
# Establishing Huge Page Availability {#hugepage-availability}
The first step towards allocating huge pages is to establish what huge pages are available to us. To do so we're going to query some files in the `/sys/kernel/mm/hugepages` directory. If any huge pages are configured, this directory will contain sub-directories for each huge page size:
@ -257,7 +257,7 @@ std::vector<HugePageInfo> HugePageInfo::load() {
}
```
### Allocating a Huge Page
# Allocating a Huge Page {#allocating}
Each huge page allocation is described by a `HugePage` structure. This structure encapsulates the virtual and physical address of an allocated huge page along with the size of the page in bytes.
@ -296,7 +296,7 @@ HugePage::Ref HugePageInfo::allocate() const {
The value that we return from `allocate` constructs a `HugePage` with the virtual address that we received from `mmap`, the equivalent physical address as calculated by our `virtual_to_physical` function and the size of the huge page.
### Deallocating a Hugepage
# Deallocating a Hugepage {#deallocating}
Once we no longer wish to retain a huge page we need to release it back into the huge page pool maintained by the operating system.
@ -309,13 +309,13 @@ HugePage::~HugePage() {
}
```
### Dividing Up a Hugepage into Buffers
# Dividing Up a Hugepage into Buffers {#dividing-into-buffers}
**Note:** _If you only wanted to know about the allocation of huge pages then you can skip to the [conclusion](#conclusion)._
When writing the interface with the Ethernet card, I needed to be able to ensure that each huge page was carved up into a number of fixed size buffers. Moreover, these buffers had specific alignment considerations that could vary by device. To facilitate this, I laid out all the buffers in a huge page as follows:
```box-drawing
```plain
A ◀─╴size╶─▶ B ◀─╴size╶─▶ ◀─╴size╶─▶
┌─────┬───┬───┬─────┬───┬─────┬───┬──────────┬───┬──────────┬─────┬──────────┐
│ C │ H │ H │ … │ H │ … │▒▒▒│ Buffer 0 │░░░│ Buffer 1 │ … │ Buffer n │
@ -635,21 +635,21 @@ DMAPool::~DMAPool() {
With the `DMAPool` implemented we can begin to portion out buffers of the required size and alignment to hardware. Hardware will require the physical address of each `Buffer` we allocate from the pool, which is available in the `Buffer::phy` field. Our process is also able to access this memory via the pointer in the `Buffer::address` field.
### Conclusion
# Conclusion {#conclusion}
Preparing memory for use with DMA may seem a bit more complex than necessary. As developers we're often shielded from the details of memory management by useful abstractions such as those provided by `malloc` and `new`. This can mean that we are rarely exposed to the manner in which memory is managed by the operating system and our programs.
I hope that this post may be of some use to those of you that need to communicate memory with devices connected to the PCI bus. You can find the complete listing as a GitHub gist:
<Bookmark
url="https://gist.github.com/BlakeRain/354a21571fa9dfe432b46b833ccec595"
title="Allocation of hugepages for DMA in Linux"
description="Allocation of hugepages for DMA in Linux. GitHub Gist: instantly share code, notes, and snippets."
author="262588213843476"
publisher="Gist"
thumbnail="https://github.githubassets.com/images/modules/gists/gist-og-image.png"
icon="https://github.githubassets.com/favicons/favicon.svg" />
```bookmark
title: Allocation of hugepages for DMA in Linux
url: "https://gist.github.com/BlakeRain/354a21571fa9dfe432b46b833ccec595"
description: |
Allocation of hugepages for DMA in Linux. GitHub Gist: instantly share code, notes, and snippets.
author: Blake Rain
publisher: GitHub Gist
thumbnail: "https://github.githubassets.com/images/modules/gists/gist-og-image.png"
icon: "https://github.githubassets.com/favicons/favicon.svg"
```
<small>
Cover image courtesy of Harrison Broadbent (<a href="https://unsplash.com/@harrisonbroadbent?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit">@harrisonbroadbent</a>) on unsplash.
</small>
> Cover image courtesy of Harrison Broadbent ([@harrisonbroadbent](https://unsplash.com/@harrisonbroadbent?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit) on unsplash)

View File

@ -3,17 +3,17 @@ title: Bitmap Tri-Color Marking
tags:
- gc
- cpp
excerpt: In this post I look at a simple tri-color marking implementation that
uses bitmap operations to walk the heap.
published: 2020-12-05T15:12:33.000Z
cover: /content/cover.jpg
cover: /content/bitmap-tri-color-marking/cover.jpg
excerpt: |
In this post I look at a simple tri-color marking implementation that uses bitmap operations to walk the heap.
---
Recently I've been experimenting with various garbage collection implementations, with an eye towards one of my possible future projects. This has lead me down all manner of paths, many of them false, and all of them great fun. One particular approach stuck with me for quite some time after I had abandoned it, so I thought I would share this approach.
Before we get going with the details of this approach, I thought I'd set the scene a little with an overview of the garbage collection mechanism known as _tri-color marking_.
### What is Tri-color Marking
# What is Tri-color Marking
Tri-color marking was first described I think by [Dijkstra et al](https://www.cs.utexas.edu/users/EWD/transcriptions/EWD05xx/EWD520.html) as part of the garbage collector for a LISP system. This algorithm is used as an enhancement to a simpler mark-and-sweep approach.
@ -130,7 +130,7 @@ These two behaviours manifested in different ways during testing. The first feat
The second behaviour the number of passes are a function of the longest object chain became quite apparent in tests that involved long chains of objects. Again, the JSON parser was a culprit of this behaviour of the marking process. If the GC executed whilst the parse tree was being referenced, multiple passes were required to walk all the nodes of the AST. Indeed this was quite likely: the JSON file was quite large, and the memory pressure increased quite drastically, often triggering multiple minor GC passes whilst the tree was being built.
### Bitmap Marking
# Bitmap Marking
The part of the approach that stuck with me was the use of bitmaps to perform the tricolor marking process. To start this off, imagine we performed allocations within a set of _blocks._ Each block describes a region of memory that our allocator will meter out for each allocation request.
@ -177,7 +177,7 @@ We can see that this represents four allocations in a block of 16 cells. The use
The last three fields of the `BlockInfo` structure are the white, grey and black sets. These bitmaps are not based on the size of a cell, but on the size of a pointer: each bit represents a region that is exactly the number of bytes in a pointer.
### Populating the Bitmaps
## Populating the Bitmaps
The first step to performing our tri-color marking is to populate the white and grey bitmaps for every block. We maintain all our blocks in a `BlockSet` structure. This structure has `begin` and `end` methods that let us iterate over the blocks in the set.
@ -546,17 +546,17 @@ black bitmap TTTTTTTT TTTT---- TTTTTTTT ------TT
XXXX
```
### Lingering Thoughts
# Lingering Thoughts
I abandoned this approach to tri-color marking in it's current guise. The process of performing the actual marking was, for most of my tests, quite performant for my needs. However, I found that the GC it was implemented in had a number of significant performance issues. Most of these were due to the way I'd implemented the GC, rather than specifically with the bitmap-based approach to tri-color marking.
#### Too Many Variables and Not Enough Rigour
## Too Many Variables and Not Enough Rigour
Fine-tuning all the variables in the GC didn't go well. There were quite a few variables, such as the size of each allocation cell in a page, the size of these pages, and so on. I never seemed to be able to balance these variables to provide a general configuration that was suitable for the range of workloads I anticipated.
I'm sure that I could have tuned these variables by taking a more rigorous approach to the design and testing of the GC. Better yet, a smarter GC could have tuned itself to a certain extent based on how it was being used. More likely would be that I would never find a "best fit" set of parameters, but I might learn something along the way.
#### Maintaining Remembered Sets
## Maintaining Remembered Sets
In order to be able to populate the white set with pointers in each block I decided to use smart pointers. This ended up being a terrible decision. The problem was exacerbated by these pointers being passed around all over the place. Turns out programs do this a lot. Who knew.
@ -572,7 +572,7 @@ I think that a more suitable approach would have been to simply stop the world a
You know, like nearly every other GC does.
#### Not Incremental or Concurrent
## Not Incremental or Concurrent
Because I was treating the tri-colour marking process as distinct from the allocator and mutator, the GC was constantly re-building white and grey bitmaps for every block, ever time it entered into a GC pass.
@ -584,7 +584,7 @@ The marking process as implemented did not lend itself to being concurrent. I di
The problem was that the marking process synchronizes the blocks by their grey bitmaps in the `promote` function. When we promote a white pointer, we fill in the grey bitmap of the pointed to block. This means that we can end up filling in the grey bitmap of a block being processed by another thread. I did find a few alternatives to this, such as work queues and incoming grey bitmaps, but it really seemed to be a bit of a hopeless pursuit by that point.
#### No Generations
## No Generations
I've saved what I felt was the the best for last: one of the biggest failings of this implementation was that there's no consideration of object generations.
@ -592,7 +592,7 @@ The generational hypothesis lends us a great advantage. If you've not heard of i
The upshot of this is that objects which are retained beyond an initial one or two passes of the GC should be moved to a subsequent generation. These later generations can be collected with a lower frequency.
### Conclusion
# Conclusion
I think that the bitmap based marking is a nice approach to tri-color, as the marking process is quite efficient. It requires virtually no memory allocation beyond a few bitmaps, and those can be allocated along with the block and reused for each pass. The main bottleneck ended up being the promotion of white pointers.

View File

@ -3,10 +3,10 @@ title: Moving my Lambda Functions to Rust
tags:
- aws
- rust
excerpt: |
My experience changing the AWS Lambda functions for this website from Python to Rust.
published: 2022-02-07T20:04:44.000Z
cover: /content/moving-lambdas-to-rust/jay-heike-Fc-0gi4YylM-unsplash.jpg
excerpt: |
My experience changing the AWS Lambda functions for this website from Python to Rust.
---
Since I've started using Rust quite a bit more at work and in some personal projects, I've been
@ -21,16 +21,19 @@ November, Aleksandr wrote about the performance of [x86 vs
ARM](https://filia-aleks.medium.com/aws-lambda-battle-x86-vs-arm-graviton2-perfromance-3581aaef75d9)
on AWS Lambda, which again showed Rust to be quite performant, and even more so on ARM.
<Bookmark
url="https://github.com/awslabs/aws-lambda-rust-runtime"
title="GitHub - awslabs/aws-lambda-rust-runtime: A Rust runtime for AWS Lambda"
description="A Rust runtime for AWS Lambda. Contribute to awslabs/aws-lambda-rust-runtime development by creating an account on GitHub."
author="awslabs"
publisher="GitHub"
thumbnail="https://opengraph.githubassets.com/e6c849253e37fbc1db7ae49d6368cc42988843123134001207eff64f7c470c9f/awslabs/aws-lambda-rust-runtime"
icon="https://github.com/fluidicon.png" />
```bookmark
url: "https://github.com/awslabs/aws-lambda-rust-runtime"
title: "GitHub - awslabs/aws-lambda-rust-runtime: A Rust runtime for AWS Lambda"
description: |
A Rust runtime for AWS Lambda. Contribute to awslabs/aws-lambda-rust-runtime development by
creating an account on GitHub.
author: awslabs
publisher: GitHub
thumbnail: "https://opengraph.githubassets.com/e6c849253e37fbc1db7ae49d6368cc42988843123134001207eff64f7c470c9f/awslabs/aws-lambda-rust-runtime"
icon: "https://github.com/fluidicon.png"
```
## Site Analytics
# Site Analytics
A few months ago I decided to change the analytics for this website over to a custom analytics
implementation, replacing my use of [Simple Analytics](https://simpleanalytics.com). The analytics
@ -51,7 +54,7 @@ point for a few reasons:
use-case for a Lambda function.
3. There's little pressure for these to be performant or stable, as it only effects this site 😆
## Building Rust for AWS Lambda
# Building Rust for AWS Lambda
I initially had a number of issues compiling Rust code for AWS Lambda using the method described in the
[README](https://github.com/awslabs/aws-lambda-rust-runtime#deployment) in the AWS Lambda Rust
@ -60,14 +63,18 @@ crate. This seemed to only arise when I compiled in Docker on the M1 when target
support for ARM in Lambda is quite recent, none of the AWS Lambda Rust build images I looked at
currently seemed to support it.
<Bookmark
url="https://github.com/softprops/lambda-rust"
title="GitHub - softprops/lambda-rust: 🐳 🦀 a dockerized lambda build env for rust applications"
description="This docker image extends lambda ci provided.al2 builder docker image, a faithful reproduction of the actual AWS 'provided.al2' Lambda runtime environment, and installs rustup and the stable rust toolchain."
author="awslabs"
publisher="GitHub"
thumbnail="https://opengraph.githubassets.com/31c9066c430630fe306c04d47e6ef314b5395bc6ce40867c9d890c2e5e13e21a/softprops/lambda-rust"
icon="https://github.com/fluidicon.png" />
```bookmark
url: "https://github.com/softprops/lambda-rust"
title: "GitHub - softprops/lambda-rust: 🐳 🦀 a dockerized lambda build env for rust applications"
description: |
This docker image extends lambda ci provided.al2 builder docker image, a faithful reproduction of
the actual AWS 'provided.al2' Lambda runtime environment, and installs rustup and the stable rust
toolchain.
author: awslabs
publisher: GitHub
thumbnail: "https://opengraph.githubassets.com/31c9066c430630fe306c04d47e6ef314b5395bc6ce40867c9d890c2e5e13e21a/softprops/lambda-rust"
icon: "https://github.com/fluidicon.png"
```
I'm sure that at some point they will support ARM, but for the time being it was necessary for me
to create a [Dockerfile] that used the `al2-arm64` image provided by AWS in the [ECR]. This
@ -91,7 +98,7 @@ cp $(ldd "$EXE_PATH" | grep ssl | awk '{print $3}') "$OUTPUT_DIR/lib/"
Now that my executables could be run by Lambda, I could start iterating the API and trigger
functions.
## Implementing the API
# Implementing the API
The initial implementation of the API in Rust has gone very easily, mostly due to the structures
provided in various crates available to Rust, including the [lambda-http] crate. I was able to
@ -171,7 +178,7 @@ using `unwrap` and `expect` and allowing the Lambda function to panic.
i32::from_str_radix(item["ViewCount"].as_n().unwrap(), 10).unwrap() // 😤
```
## Implementing the Trigger
# Implementing the Trigger
Once I had come to understand the structures and functions in the Rust AWS client crates, I had a
far easier time building the trigger function. This function simply responds to events received
@ -184,7 +191,7 @@ I was somewhat worried about the parsing of user agent strings: in Python I did
[woothee](https://crates.io/crates/woothee) that performs the same operation just as well for my
use case.
## Conclusion
# Conclusion
I was pleasantly suprised at how well the process went. Apart from the somewhat slow start getting
Rust code compiled for AWS Lambda on ARM, once I had my bearings it was quite easy going.
@ -207,6 +214,4 @@ Rust Lambda functions found in the
[named-value]: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_AttributeValue.html
[attributevalue]: https://docs.rs/aws-sdk-dynamodb/0.6.0/aws_sdk_dynamodb/model/enum.AttributeValue.html
<small>
Cover image courtesy of Jay Heike (<a href="https://unsplash.com/@jayrheike">@jayrheike</a>) on unsplash.
</small>
> Cover image courtesy of Jay Heike ([@jayrheike](https://unsplash.com/@jayrheike) on unsplash).

View File

@ -2,11 +2,11 @@
title: Moving to Mastodon
tags:
- mastodon
excerpt: |
Much like a great number of other people, I have followed the trend towards Mastodon, and have found it to be quite a
wonderful experience.
published: 2022-11-13T15:38:33.000Z
cover: /content/moving-to-mastodon/cover.jpg
excerpt: |
Much like a great number of other people, I have followed the trend towards Mastodon, and have
found it to be quite a wonderful experience.
---
This week one of my favourite actors [Stephen Fry] posted a final message to his now closed Twitter account and moved
@ -22,14 +22,15 @@ tweets were at times few and far between.
In the same Reddit thread I learned that he had already created an account on the [mastodonapp.uk] instance where, at
the time of writing, he has already amassed 51k followers.
<Bookmark
url="https://mastodonapp.uk/@stephenfry"
title="@stephenfry@mastodonapp.uk"
description="Stephen Fry on Mastodon"
author="Stephen Fry"
publisher="Mastodon"
thumbnail="/content/moving-to-mastodon/fry-mastodon.jpg"
icon="https://cdn.simpleicons.org/mastodon/6364FF" />
```bookmark
url: "https://mastodonapp.uk/@stephenfry"
title: "@stephenfry@mastodonapp.uk"
description: Stephen Fry on Mastodon
author: Stephen Fry
publisher: Mastodon
thumbnail: "/content/moving-to-mastodon/fry-mastodon.jpg"
icon: "https://cdn.simpleicons.org/mastodon/6364FF"
```
Not wanting to be left out of what appeared to be a growing trend, Fry's departure from Twitter provided the impetus for
me to give Mastodon a go. On Wednesday I decided to create my own Mastodon account, on the same [mastodonapp.uk]
@ -70,12 +71,15 @@ One of the people I found was Hugh Rundle. Hugh has made some interesting points
think is an important read to anybody moving to Mastodon from Twitter. Hugh tells an important part of the story that
I'm not sure is being considered.
<Quote author="Hugh Rundle" url="https://www.hughrundle.net/home-invasion/">
There's another, smaller group of people mourning a social media experience that was destroyed this week — the people
who were active on Mastodon and the broader fediverse prior to November 2022. The nightclub has a new brash owner, and
the dancefloor has emptied. People are pouring in to the quiet houseparty around the corner, cocktails still in hand,
demanding that the music be turned up, walking mud into the carpet, and yelling over the top of the quiet conversation.
</Quote>
```quote
author: Hugh Rundle
url: "https://www.hughrundle.net/home-invasion/"
quote: |
There's another, smaller group of people mourning a social media experience that was destroyed this week — the people
who were active on Mastodon and the broader fediverse prior to November 2022. The nightclub has a new brash owner, and
the dancefloor has emptied. People are pouring in to the quiet houseparty around the corner, cocktails still in hand,
demanding that the music be turned up, walking mud into the carpet, and yelling over the top of the quiet conversation.
```
I really hope that we can behave in a way that doesn't ruin the house party.
@ -90,7 +94,7 @@ has a green check, as my website has a link back to my Mastodon profile.
This was achieved by adding a `rel="me"` to the anchor that links back to my Mastodon profile in the navigation header
at the top of this site:
```typescript
```ts
const MastodonLink: FC = () => {
return (
<a

View File

@ -4,10 +4,11 @@ tags:
- ghost-tag
- javascript
- react
excerpt: In this post I outline the steps that I took to make this blog a static
site, whilst still maintaining some of the advantages of the Ghost CMS.
published: 2021-08-01T17:41:22.000Z
cover: /content/Screenshot-2021-08-01-at-18.29.16.jpg
cover: /content/moving-towards-jamstack-with-netlify/cover.jpg
excerpt: |
In this post I outline the steps that I took to make this blog a static site, whilst still
maintaining some of the advantages of the Ghost CMS.
---
I've recently been tinkering with the serverless approach to application development and hosting. I decided to change this website to an entirely static site. However I didn't want to just use a GitHub repository of Markdown files, preferring to maintain some of the advantages of the Ghost CMS.
@ -26,7 +27,7 @@ My goal was to remove the EC2 and RDS instances and change the structure of the
1. Images would be stored in Amazon S3 by a custom storage adapter, and
1. A static site is generated, and then hosted by [Netlify](https://www.netlify.com).
### Ghost and Docker
# Ghost and Docker
I wanted to move the Ghost CMS from the EC2 instance into a Docker container on a local server at my home. To build this Docker container I used the [official Docker image](http://localhost:2368/p/754d8315-38fa-49ad-8ac1-62ffc1f02c2e/) as the base. I needed to add a [custom storage adapter](https://ghost.org/docs/config/#creating-a-custom-storage-adapter) that would make use of the AWS SDK to store images in S3. Therefore I needed to ensure that the [AWS SDK](https://www.npmjs.com/package/aws-sdk) was available in the image.
@ -36,22 +37,25 @@ Once I had the Ghost instance up and running, migrating the data from one instan
There was one issue I had that ended up taking some time to remediate: the changeover of the storage adapter. Because I'd changed over to using S3 as the storage back-end, the URLs for the images in each of the blog posts was now incorrect. The first fix I considered was using SQL to find-and-replace all the URLs in the posts. However, in the end I opted for just editing each post and replacing the image. This is quite easy to do with the Ghost authoring tools. Moreover, this also gave me the opportunity to fix some of the screenshots.
### Generating the Static Site
# Generating the Static Site
In order to render the site I decided to use React Static: a static site generator for React. I chose this approach over other [much easier options](https://ghost.org/docs/jamstack/) as I wanted to move away from Ghost themes and I really enjoy using React :)
<Bookmark
url="https://github.com/react-static/react-static"
title="GitHub - react-static/react-static: ⚛️ 🚀 A progressive static site generator for React."
description="⚛️ 🚀 A progressive static site generator for React. - GitHub - react-static/react-static: ⚛️ 🚀 A progressive static site generator for React."
author="react-static"
publisher="GitHub"
thumbnail="https://repository-images.githubusercontent.com/102987907/733d9200-6288-11e9-9f58-538c156753f8"
icon="https://github.com/fluidicon.png" />
```bookmark
url: "https://github.com/react-static/react-static"
title: "GitHub - react-static/react-static: ⚛️ 🚀 A progressive static site generator for React."
description: |
⚛️ 🚀 A progressive static site generator for React. - GitHub - react-static/react-static:
⚛️ 🚀 A progressive static site generator for React.
author: react-static
publisher: GitHub
thumbnail: "https://repository-images.githubusercontent.com/102987907/733d9200-6288-11e9-9f58-538c156753f8"
icon: "https://github.com/fluidicon.png"
```
I used the Ghost [Content API](https://ghost.org/docs/content-api/) to extract the navigation, posts, and pages. I then render them using React. The site is a very simple React application, with only a few components.
### Deploying to Netlify
# Deploying to Netlify
Deploying the site to Netlify is as easy as using the [Netlify CLI](https://docs.netlify.com/cli/get-started/) on the command line after building the static site using React Static. All I required was a Netlify personal access token and the API ID of the site. Both of which can be easilly found in the Netlify interface.
@ -72,28 +76,32 @@ In order to achieve this I created a new Docker container on the same machine. T
As a security precaution, GitHub encourage you to not attach a self-hosted runner to a public repository. Therefore it was necessary for me to create a private repository which contains the workflow for building and deploying the site. As the repository is private, I have reproduced the workflow as a Gist:
<Bookmark
url="https://gist.github.com/BlakeRain/cae8edfa273d7603d25e5527c6821984"
title="Workflow to build and deploy the static blakerain.com"
description="Workflow to build and deploy the static blakerain.com - deploy.yml"
author="262588213843476"
publisher="Gist"
thumbnail="https://github.githubassets.com/images/modules/gists/gist-og-image.png"
icon="https://gist.github.com/fluidicon.png" />
```bookmark
url: "https://gist.github.com/BlakeRain/cae8edfa273d7603d25e5527c6821984"
title: "Workflow to build and deploy the static blakerain.com"
description: "Workflow to build and deploy the static blakerain.com - deploy.yml"
author: Blake Rain
publisher: GitHub Gist
thumbnail: "https://github.githubassets.com/images/modules/gists/gist-og-image.png"
icon: "https://gist.github.com/fluidicon.png"
```
The final piece of the puzzle was to connect Ghost to GitHub: when I make a change to the site I wanted the GitHub workflow to execute. As the GitHub API requires authentication, I created a small [lambda function](https://github.com/BlakeRain/blakerain.com/blob/main/lambda/ghost-post-actions/index.js). This function processes the POST request from the Ghost CMS [webhook](https://ghost.org/docs/webhooks/) and in turn makes a call to the GitHub API to trigger a [workflow dispatch event](https://docs.github.com/en/rest/reference/actions#create-a-workflow-dispatch-event).
### Final Thoughts
# Final Thoughts
Now that I have a static version of the site, hosted for free at Netlify, I'm sure that I'll enjoy the cost saving (around $55 per month). Moreover the site loads significantly faster from the Netlify CDN than it did from the little EC2 instance. I feel much safer with the Ghost CMS administration interface running on a local server rather than it being exposed to the Internet.
As before, all the sources for the site are available on GitHub. This includes the cobbled together bits and pieces for the S3 storage adapter and the GitHub Actions Runner Docker image.
<Bookmark
url="https://github.com/BlakeRain/blakerain.com"
title="GitHub - BlakeRain/blakerain.com: Repository for the static generator for my blog"
description="Repository for the static generator for my blog. Contribute to BlakeRain/blakerain.com development by creating an account on GitHub."
author="BlakeRain"
publisher="GitHub"
thumbnail="https://repository-images.githubusercontent.com/155570276/9e808f00-3b06-11eb-913d-44e30d832a70"
icon="https://github.com/fluidicon.png" />
```bookmark
url: "https://github.com/BlakeRain/blakerain.com"
title: "GitHub - BlakeRain/blakerain.com: Repository for the static generator for my blog"
description: |
Repository for the static generator for my blog. Contribute to BlakeRain/blakerain.com development
by creating an account on GitHub.
author: BlakeRain
publisher: GitHub
thumbnail: "https://repository-images.githubusercontent.com/155570276/9e808f00-3b06-11eb-913d-44e30d832a70"
icon: "https://github.com/fluidicon.png"
```

View File

@ -4,10 +4,11 @@ tags:
- ghost-tag
- linux
- aws
excerpt: I have a new website and blog using the Ghost platform. Here I touch on
the setup and the goals for this site.
published: 2019-10-31T19:44:00.000Z
cover: /content/nong-vang-h6-KSsXLSkI-unsplash.jpg
cover: /content/new-site-and-blog/cover.jpg
excerpt: |
I have a new website and blog using the Ghost platform. Here I touch on the setup and the goals for
this site.
---
**tl;dr** New website using [Ghost](https://ghost.org/) was easy to set up.
@ -18,18 +19,21 @@ So for this first post I wanted to share some information relating to how I set
See here for adding search: [https://blakerain.com/blog/adding-search-to-ghost](http://localhost:2368/blog/adding-search-to-ghost)
### Ghost CMS
# Ghost CMS
I've used [Ghost](https://ghost.org) to create this website. Ghost is a CMS, written in JavaScript, that provides a nice set of features without seeming to be too bloated.
<Bookmark
url="https://ghost.org"
title="Ghost: The #1 open source headless Node.js CMS"
description="The worlds most popular modern open source publishing platform. A headless Node.js CMS used by Apple, Sky News, Tinder and thousands more. MIT licensed, with 30k+ stars on Github."
author="Albert Henk van Urkalberthenk.com"
publisher="Ghost"
thumbnail="https://ghost.org/images/meta/Ghost.png"
icon="https://ghost.org/icons/icon-512x512.png?v=188b8b6d743c6338ba2eab2e35bab4f5" />
```bookmark
url: "https://ghost.org"
title: "Ghost: The #1 open source headless Node.js CMS"
description: |
The worlds most popular modern open source publishing platform. A headless Node.js CMS used by
Apple, Sky News, Tinder and thousands more. MIT licensed, with 30k+ stars on Github.
publisher: Ghost
author: "Albert Henk van Urkalberthenk.com"
thumbnail: "https://ghost.org/images/meta/Ghost.png"
icon: "https://ghost.org/icons/icon-512x512.png?v=188b8b6d743c6338ba2eab2e35bab4f5"
```
Apart from it's small size and not being built in PHP, some of the features that attracted me to Ghost are:
@ -41,18 +45,21 @@ Apart from it's small size and not being built in PHP, some of the features that
- I quite like working with JavaScript.
- Ghost seemed easy to self-host, which is usually my preferred option.
### Deploying Ghost
# Deploying Ghost
For the most part, installation of Ghost required following the instructions on the Ghost website. I roughly followed the guide for Ubuntu, as that is the distribution I chose:
<Bookmark
url="https://ghost.org/docs/install/ubuntu/"
title="How to install & setup Ghost on Ubuntu 16.04 + 18.04"
description="A full production install guide for how to install the Ghost professional publishing platform on a production server running Ubuntu 16.04 or 18.04."
author="Ghost"
publisher="Ghost"
thumbnail="https://ghost.org/images/meta/Ghost-Docs.jpg"
icon="https://ghost.org/icons/icon-512x512.png?v=188b8b6d743c6338ba2eab2e35bab4f5" />
```bookmark
url: "https://ghost.org/docs/install/ubuntu/"
title: "How to install & setup Ghost on Ubuntu 16.04 + 18.04"
description: |
A full production install guide for how to install the Ghost professional publishing platform on
a production server running Ubuntu 16.04 or 18.04.
author: Ghost
publisher: Ghost
thumbnail: "https://ghost.org/images/meta/ghost-docs.png"
icon: "https://ghost.org/icons/icon-512x512.png?v=188b8b6d743c6338ba2eab2e35bab4f5"
```
To deploy Ghost I first created an AWS instance with Ubuntu 18.04 and attached an EIP to it. I used an EIP in case I needed to replace the instance (such as if it entered a degraded state), or I wanted to use an ELB or some other magic. I configured the security group for the instance's sole network interface to allow SSH from my own IP along with HTTP and HTTPS from anywhere else:
@ -93,7 +100,7 @@ Finally I could make sure that the site was running using `ghost ls`, which gave
![](/content/new-site-and-blog/image-17.png)
### Customizing Ghost
# Customizing Ghost
Once I had an installation that was working I wanted to be able to customize it. The first thing I wanted to do was to make sure that the site was not generally available. Conveniently Ghost includes a simple way of doing this by switching the site to private, disabling access, SEO and social features. This option can be found in the **General** settings of the administration portal:
@ -103,24 +110,30 @@ Once the site was private I felt more confident playing around with the Ghost se
There are a lot of integrations that can work with Ghost, which are listed on the Ghost website:
<Bookmark
url="https://ghost.org/integrations/"
title="Ghost Integrations Connect your favourite Tools & Apps to your site"
description="Keep your stack aligned and integrate your most used tools & apps with your Ghost site: automation, analytics, marketing, support and much more! 👉"
publisher="Ghost"
thumbnail="https://ghost.org/images/meta/Ghost-Integrations.jpg"
icon="https://ghost.org/icons/icon-512x512.png?v=188b8b6d743c6338ba2eab2e35bab4f5" />
```bookmark
url: "https://ghost.org/integrations/"
title: "Ghost Integrations Connect your favourite Tools & Apps to your site"
description: |
Keep your stack aligned and integrate your most used tools & apps with your Ghost site:
automation, analytics, marketing, support and much more! 👉
publisher: Ghost
thumbnail: "https://ghost.org/images/meta/ghost-integrations.png"
icon: "https://ghost.org/icons/icon-512x512.png?v=188b8b6d743c6338ba2eab2e35bab4f5"
```
I've really only used the built-in [Unsplash](https://ghost.org/integrations/unsplash/) integration along with [Commento](https://ghost.org/integrations/commento/) to provide embedded comment threads.
After messing around with the tags, routing and other settings it was time to settle on a theme. Ghost has a large set of themes available on their [marketplace](https://ghost.org/marketplace/), many of which are free to use if you're so inclined.
<Bookmark
url="https://ghost.org/marketplace/"
title="Ghost Themes - The Marketplace"
description="Discover beautiful professional themes for the Ghost publishing platform. Custom templates for magazines, blogs, news websites, content marketing & more!"
author="Ghost"
icon="https://ghost.org/icons/icon-512x512.png?v=188b8b6d743c6338ba2eab2e35bab4f5" />
```bookmark
url: "https://ghost.org/marketplace/"
title: "Ghost Themes - The Marketplace"
description: |
Discover beautiful professional themes for the Ghost publishing platform. Custom templates for
magazines, blogs, news websites, content marketing & more!
author: Ghost
icon: "https://ghost.org/icons/icon-512x512.png?v=188b8b6d743c6338ba2eab2e35bab4f5"
```
After much indecision I decided to settle on the default theme for Ghost, which is called [Casper](https://demo.ghost.io). The theme that I am using is actually a slightly modified version. I only made a few minor changes, mostly relating to colors and to add a few custom templates. You can find the sources for my customization here:

View File

@ -3,10 +3,11 @@ title: Overlays with Custom Widgets in GTK
tags:
- python
- gtk
excerpt: In this post I show how to build a collapsible controls overlay in GTK,
rendered over a simple custom widget.
published: 2021-01-14T21:27:19.000Z
cover: /content/Collapsible-Controls-Overlay-Demo_2102.png
cover: /content/overlays-with-custom-widgets-in-gtk/cover.png
excerpt: |
In this post I show how to build a collapsible controls overlay in GTK, rendered over a simple
custom widget.
---
Recently I've been building a GTK application that includes a custom drawing widget for editing a simple 2D map. When elements are selected in the map I wanted a nice way to edit those elements within the map itself.
@ -29,7 +30,7 @@ It's interesting to me that we don't often see this kind of UI in many GTK appli
In this article I thought it would be fun to walk through creating a simple GTK application that uses an [overlay](https://developer.gnome.org/gtk3/stable/GtkOverlay.html) widget to render a set of controls over the top of a custom drawn widget.
![Simple application using an overlay widget](/content/overlays-with-custom-widgets-in-gtk/Collapsible-Controls-Overlay-Demo_2102-1.png)
![Simple application using an overlay widget](/content/overlays-with-custom-widgets-in-gtk/Collapsible-Controls-Overlay-Demo_2102.png)
As we're only focusing on the GTK side of things, I decided to use Python instead of C++.
@ -645,11 +646,12 @@ After this final change to the `ControlPanel` widget we should have a demo that
If you want to download the source code for this demo you can find it at the following GitHub Gist:
<Bookmark
url="https://gist.github.com/BlakeRain/f62732c37dcb3a4950134a9b37d4913b"
title="collapse-controls.py"
description="GitHub Gist: instantly share code, notes, and snippets."
author="262588213843476"
publisher="Gist"
thumbnail="https://github.githubassets.com/images/modules/gists/gist-og-image.png"
icon="https://github.githubassets.com/favicons/favicon.svg" />
```bookmark
title: collapsible-controls.py
url: "https://gist.github.com/BlakeRain/f62732c37dcb3a4950134a9b37d4913b"
description: "GitHub Gist: instantly share code, notes, and snippets."
author: Blake Rain
publisher: GitHub Gist
thumbnail: "https://github.githubassets.com/images/modules/gists/gist-og-image.png"
icon: "https://github.githubassets.com/favicons/favicon.svg"
```

View File

@ -0,0 +1,744 @@
---
title: SSG and Hydration with Yew
tags:
- rust
- yew
published: 2023-08-30T18:50:17.000Z
cover: /content/ssg-and-hydration-with-yew/cover.jpeg
excerpt: |
In this post I go over how I rewrote this website in Rust using the Yew framework with SSG and
hydration. I cover a number of the issues that I encountered and how I addressed them.
---
For some time now, I've been hoping to update this website to move away from using [Next.js] with
TypeScript towards something written in [Rust], ideally making use of [WebAssembly].
[At work](https://eclipse-pci.com/) we've had quite a lot of success using WebAssembly, both for
some of our web interfaces, and to provide extensibility for our device software.
This has left me quite keen to explore the opportunity to use WebAssembly, along with the [Yew]
web framework, to create a statically generated website that uses hydration to provide
interactivity. This project, whilst complete, has not been without some frustrations and issues.
I felt it might be worth documenting some of the more interesting problems I encountered along the
way.
# What is SSG and Hydration? {#what-is-ssg-and-hydration}
```callout type=note
If you're already familiar with this topic, you might want to skip ahead to the next section.
```
These days, it is fairly common for web applications to use libraries such as [React] or [Vue.js]
to power their user interface. These libraries typically offer excellent approaches to building
powerful interactive interfaces on the web. It is not uncommon for web applications to be developed
as a Single Page Application (SPA), where a single HTML page is loaded which provides the entire
application.
There are some drawbacks to using an SPA, which can include:
- It can be next to impossible for a search engine bot to crawl the content of an SPA. Typically
search engine crawlers do not execute JavaScript, and therefore site content is often not indexed.
- As SPAs can entail large amounts of JavaScript that must be loaded by the browser prior to
execution, there can be a noticeable delay before the site is displayed.
Static Site Generation (SSG) describes a methodology in which the HTML for each page of a site
is generated ahead of time as part of a build process, as opposed to upon each HTTP request. The
generated HTML can be served as static content, such as via a CDN. This approach is best suited for
websites where the content does not change very often.
Various frameworks exist, such as [Next.js] and [Gatsby], that help mitigate the drawbacks of SPAs
whilst maintaining the advantages of libraries such as React or Vue.js. Often these frameworks use
SSG as a mechanism to achieve this. In order to maintain the rich interactive interface afforded by
an SPA, frameworks will offer support for _hydration_: a process whereby client-side JavaScript
attaches to the existing HTML served in the initial response, rather than generating it.
# The Yew Web Framework {#the-yew-web-framework}
[Yew] is a component-based framework for [Rust] that is similar to [React], and typically compiles
to [WebAssembly]. Tools like [Trunk] can make the processes of building an SPA using Yew quite a
delightful experience.
Yew includes support for [Server-Side Rendering], which allows us to render the site on the server
in response to a request.
SSR with Yew is quite easy to work with: rather than use the [`Renderer`] to mount our application
in the `<body>` element of our page, I use the [`LocalServerRenderer`] type to render the initial
HTML of our application.
```rs
use yew::LocalServerRenderer;
async fn render_my_app() -> String {
let renderer = LocalServerRenderer::<MyApp>::new();
renderer.render().await
}
```
Yew also includes support for [SSR hydration], where the client-side Yew application attaches to
the HTML generated server-side. Using the `hydrate` method of the `Renderer` type will hydrate all
elements under the `<body>` element of our page.
```rs
use yew::Renderer;
fn hydrate_my_app() {
let renderer = Renderer::<MyApp>::new();
renderer.hydrate();
}
```
```callout type=tip
The `LocalServerRenderer` and hydration support will require the `ssr` and `hydration` features to
be enabled for the `yew` crate.
```
This is very close to what I needed in order to achieve my goal of a hydrating SSG using WebAssembly:
1. I knew that I could generate some static HTML from a Yew application (SSG), and
2. Once loaded into the browser, I could attach a Yew application to that HTML (hydration).
# Preparing for Hydration {#preparing-for-hydration}
In order for the dynamic elements of the site to be available once the statically generated HTML has
been loaded by the web browser, the Yew application needs to be attached to the DOM using hydration.
In order to enable component hydration in Yew, the `hydration` feature needs to be enabled for the
`yew` crate. I also needed to make sure to call the [`Rendered::hydrate`] method rather than the
usual `render` method.
To ensure this is the case, I added a `hydration` feature to the main crate and this flag is set
when building using Trunk:
```
trunk build --release --features hydration
```
This generates the `index.html` that I use as a template in the SSG along with the WebAssembly of
the hydrating application.
## Using Phantom Components {#phantom-components}
During hydration, I needed to make sure that the elements found in the HTML generated during SSG and
loaded into the browser correspond exactly to those the hydrating application expects. This is
mostly fairly simple, but there is one good example of where it is not so easy: the use of the
`HeadContext` in the `StaticApp` component. When rendering the normal `App` used during a hydration
build I do not have access to a `HeadContext`. As such, I need to use a [`PhantomComponent`] to
tell Yew that there would have been a component of the given type in that location.
```rust
#[function_component(App)]
pub fn app() -> Html {
html! {
<PhantomComponent<ContextProvider<HeadContext>>>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</PhantomComponent<ContextProvider<HeadContext>>>
}
}
```
## Rendering Markdown {#rendering-markdown}
The main purpose of this site is to render some Markdown. I have fairly particular requirements for
the HTML generated from these Markdown documents. For example, I want images to be included with
an `<img>` tag within a `<figure>`, rather than inside a `<p>`, which is what is usually generated.
Getting the rendering of Markdown took me _four attempts_ to get right, which is both frustrating
and embarrassing.
### First Attempt
My first attempt simply compiled the original Markdown documents into the WebAssembly by using the
[include_dir] crate. I was then able to parse the Markdown and render it.
Unfortunately this had the drawback of including all the code to parse Markdown and render it,
increasing the size of the WebAssembly quite a lot. Additionally, it imposed quite a fair amount of
processing on each navigation.
Once I added in support for syntax highlighting, the size of the WebAssembly increased
_dramatically_, and the rendering speed was significantly affected. The latter was more pronounced
on documents with many separate code blocks, which is pretty common on a programming blog.
### Second Attempt
To address the issues I encountered with the first attempt, I decided that it would be better to
parse the Markdown and render the HTML, including performing the syntax highlighting, during the
compilation of the application. I would then just include the generated HTML in the WebAssembly.
I did this by splitting out the representation of a document into a separate `model` crate, and then
creating a `macros` crate that would contain the macros for this process. Whilst I was at it, I
also changed the parsing of the document metadata and the tags into their corresponding structures
to use macros as well.
With these macros in place, I could include all the markdown and tags during compilation:
```rust
macros::tags!("content/tags.yaml");
mod blog {
macros::documents!("content/blog");
}
mod pages {
macros::documents!("content/pages");
}
```
The `tags!` macro generates a `TagId` enumeration for all the tags parsed from the YAML file. The
`TagId` enumeration implements the `Display` and `FromStr` traits that enable conversion to and from
the slug for each tag. Additionally, a `tags()` function is generated that returns a `HashMap`
mapping each slug to the corresponding `Tag` structure from the `model` crate.
The `documents!` macro is the more heavy-hitting macro: it parses all Markdown documents in a
directory. A `DocId` enumeration is generated that contains an enumerator for each document. The
enumeration implements the `Display` and `FromStr` traits that allow conversion between a `DocId`
and the document slug (essentially the file name). Because of these two traits, the `DocId` type can
be used in the `Route` type.
Two functions are also produced by the `documents!` macro: the `documents()` function that returns a
`Vec` of `model::Details` structures describing the metadata of all the Markdown documents parsed
from the directory. The second function, `render()`, takes a `DocId` and returns the rendered HTML
as a `&'static str`.
The rendered HTML for a document, retrieved as an `str` reference from the generated `render()`
function, was wrapped in a Yew [`VRaw`]. The `VRaw` type allows us to insert raw HTML into an
application.
This worked very well at first. The pages loaded very quickly, and the rendered HTML strings were
not that large. Unfortunately this quickly ran into a problem: just like a `VPortal`, Yew cannot
hydrate a `VRaw` 🤬.
### Third Attempt
Well I was getting fairly frustrated now. It seemed that I really needed to create a full virtual
DOM for each document so that Yew could both render it during SSG and hydrate it once loaded. I
couldn't use raw HTML, as that could not be hydrated.
So, I proceeded to change the document rendering function from one that would simply return a
reference to a string, to one that would build a virtual DOM:
```rust
// pub fn render(ident: DocId) -> Option<(Details<DocId>, &'static str)> NOPE
pub fn render(ident: DocId) -> Option<(Details<DocId>, Html)>
```
The `documents!` macro was changed quite significantly to generate code using a combination of Yew's
`html!` macro and raw generation of the `VNode` values.
This approach seemed to be working well. As I handled more of the variants of the [`Event`] and [`Tag`]
enumerations that came from the Markdown parser, the size of the WebAssembly started to increase
quite dramatically. The release build was fairly hefty, however the debug build quickly ballooned to
over 20MB in size.
This started to push up against some limits in some of the tooling that I was using. For example,
I was running into a limit on the maximum [number of locals] in the `wasmparser` crate. At the time
of writing, this limit is currently set to 50,000. This is not actually an arbitrary value, this
limit can also bee seen in [Firefox] and [Chrome]. And honestly, exceeding fifty thousand locals in
a function is quite concerning.
Whilst I was still able to use the generated code when compiling with optimisations enabled, it
quickly became difficult to work with. Moreover, the size of the WebAssembly was starting to get
quite large, even in release mode.
I considered refactoring the code to try and break up the rendering functions somewhat, but that
would required finding out exactly where Rust was generating all these locals, and I really just ran
out of fucks to give by this point.
### Forth and Final Attempt
After a break, I embarked on what I hoped was my final attempt. I would rebuild the Markdown
renderer... again. This time, however, it would not generate HTML as a string, or try and generate a
`VNode`: instead it would generate a bytestring. That bytestring would contain a simplified virtual
DOM that would be serialized using the [postcard] crate. Then, on the client side, I would write a
function that maps the deserialized mini-DOM to Yew's `VNode` structure.
To start with, I defined a `RenderNode` enumeration that contained the three types of node I wanted
to render: text, HTML elements and icons images.
```rust
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RenderNode {
Text(RenderText),
Element(RenderElement),
Icon(RenderIcon),
}
```
The `RenderText` type was simply a wrapper around a `String`, and the `RenderIcon` was an
enumeration of the icons that I needed during rendering of the Markdown documents. The
`RenderElement` type was a structure that encapsulated the name of the tag, any attributes, and any
children.
```rust
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RenderElement {
pub tag: TagName,
pub attributes: Vec<RenderAttribute>,
pub children: Vec<RenderNode>,
}
```
Notice that the `tag` field is the `TagName` type, which is an enumeration of all the tag names that
I need during rendering. This allows me to use `TagName::Div` or `TagName::Figure` rather than the
strings `"div"` or `"figure"`. This saves quite a lot of space, and improves performance when I need
to map a `RenderNode` to a `VNode` on the client as I'll only be comparing discriminators rather
than strings.
The `attributes` field is a simple `Vec` of a `RenderAttribute` structure. This structure pairs an
`AttributeName` with a value for that attribute in a `String`.
```rust
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RenderAttribute {
pub name: AttributeName,
pub value: String,
}
```
Just like the `TagName` enumeration, each attribute that I need is described by an `AttributeName`
enumeration, such as `AttributeName::Class` or `AttributeName::Href`.
During rendering, each `RenderNode` can be rendered to `Html` with a simple `match` statement:
```rust
fn render_node(node: &RenderNode) -> Html {
match node {
RenderNode::Text(RenderText { content }) =>
VText::new(content.to_string()).into(),
RenderNode::Element(RenderElement {
tag,
attributes,
children,
}) => {
let mut tag = VTag::new(tag.as_str());
for attribute in attributes {
tag.add_attribute(
attribute.name.as_str(),
attribute.value.to_string()
);
}
for child in children {
let child = render_node(child);
tag.add_child(child);
}
tag.into()
}
RenderNode::Icon(icon) => {
html! { <Icon icon_id={icon_id_for_icon(*icon)} /> }
}
}
}
```
Notice that the `TagName` and `AttributeName` enumerations both have a `to_string()` method that
returns a `&'static str` suitable for use with the `VTag::new` and `VTag::add_attribute()` methods.
Whilst the `VTag::new()` function can take anything that implements `Into<Cow<str>>`, the
[`VTag::add_attribute()`] actually requires the attribute name to be a `&'static str`. It's almost
like planning something pays off 😌.
Examining the output from this attempt, the size of the generated byte string is never especially
large. The following table lists the sizes for three of the blog posts in this site.
| Post | Markdown | Byte String | Compressed Byte String | SSG HTML |
| ------------------------------------- | -------: | ----------: | ---------------------: | -------: |
| [Moving to Mastodon] | 10k | 11k | 5k | 56k |
| [Allocating memory for DMA in Linux] | 36k | 57k | 15k | 118k |
| [Overlays with Custom Widgets in GTK] | 35k | 72k | 12k | 141k |
The last post in the table, [Overlays with Custom Widgets in GTK], is the largest of the generated
byte strings at 72kb. As you can see from the second-to-last column, the byte strings compress quite
well, which helps with the transmission of the produced WebAssembly.
# Static Generation {#static-generation}
To achieve the static site generation (SSG), I built a simple tool as part of the site called
[site-build]. This tool generates the static version of the site by taking the following steps:
1. Retrieve the `index.html` output by the Trunk tool and use it as a template for each generated
HTML page. It is into this template that the tool inserts the HTML rendered by Yew's
`LocalServerRenderer`.
1. Render each of the routes of the application.
1. Copy over all the remaining assets output by Trunk, which will include the WebAssembly and
JavaScript glue.
1. Generate things like the `sitemap.xml`, and the Atom and RSS feeds.
## HTML Template {#html-template}
Each page that is rendered does so by injecting the output of the `yew::LocalServerRenderer` into an
HTML template; remember that Yew will expect to hydrate into the `<body>` element, so I must also
place the rendered page content in the same location.
```html
<!DOCTYPE html>
<html>
<head>
...
</head>
<body>
<!-- RENDER GOES HERE -->
</body>
</html>
```
The HTML that I used as a template was the `index.html` output by Trunk. Trunk essentially
takes an `index.html` as an input, processes any asset instructions found within, and outputs those
assets along with a new `index.html`. All this is typically output into a `dist` directory.
The input `index.html` contains instructions for the processing of assets. Each instruction is given
as a `<link>` element with the `data-trunk` attribute. For example, I want to copy all the files and
directories under the `public/content` directory to the `dist` directory for deployment. To do this,
I can tell Trunk to copy the entire directory:
```html
<link data-trunk rel="copy-dir" href="public/content" />
```
The CSS for the application is compiled by [postcss] into a single CSS file. I want this CSS file to
be included in the HTML file, and Trunk can do this:
```html
<link data-trunk rel="inline" type="css" href="target/main.css" />
```
The final `index.html` is generated by running `trunk build`, which compiles all the resources into
the `dist` directory along with the `index.html`.
The `site-build` program loads the `index.html` from the `dist` directory and marks the insertion
locations for the `<body>` and `<head>` tags. These locations are used to insert rendered HTML into
both the `<head>` and `<body>` elements of each page.
## Rendering Routes {#rendering-routes}
In order to render all the routes, I first needed both a function that could take a `Route` and
render it to an HTML file and some `Iterator` over `Route`. In order to get an iterator over the
routes in the app I used the [`Sequence`] trait from the [enum-iterator] crate. I was then able to
iterator over all the routes using the [`all`] function to collect up all the routes:
```rust
struct RenderRoute {
pub route: Route,
pub path: PathBuf,
}
fn collect_routes() -> Vec<RenderRoute> {
enum_iterator::all::<Route>()
.map(|route| {
let path = route.to_path();
let path = if path == "/" {
PathBuf::from("index.html")
} else {
PathBuf::from(&path[1..]).with_extension("html")
};
RenderRoute { route, path }
})
.collect()
}
```
The `collect_routes` function iterates over all the routes in the app and collects them into a `Vec`
of `RenderRoute`. For each route, the `RenderRoute` structure tells us:
1. The `route` to render. This is passed to the Yew application when the page needs to be rendered,
and tells our router which page to render.
1. A `PathBuf` that contains the relative path to the output HTML file. A special case is present to
handle the empty route, and use `index.html` instead.
The rendering of each route is performed by a `render_route` function. This function create a Yew
app for static rendering and uses the [`render_to_string`] method of the [`LocalServerRenderer`] type
to render the HTML. The contents are then injected into the HTML template and written to the file
specified in the `RenderRoute::path` field.
In order to perform the static rendering, I needed to swap out the router used by the application.
Normally, the application uses the [`BrowserRouter`] type. This uses the browser's native history to
handle the routing, and is typically the router that you want to use in an application.
However, for the static rendering there is no [`BrowserHistory`] as this code is not running in a
web browser. Conveniently, there is an in-memory history as part of the Yew router called
[`MemoryHistory`]. I used this to create a [`Router`] component as opposed to a [`BrowserRouter`].
```rust
#[derive(Properties, PartialEq)]
pub struct StaticAppProps {
pub route: Route,
pub head: HeadContext,
}
impl StaticAppProps {
fn create_history(&self) -> AnyHistory {
let path = self.route.to_path();
let history = MemoryHistory::with_entries(vec![path]);
history.into()
}
}
#[function_component(StaticApp)]
pub fn static_app(props: &StaticAppProps) -> Html {
let history = props.create_history();
html! {
<ContextProvider<HeadContext> context={props.head.clone()}>
<Router history={history}>
<AppContent />
</Router>
</ContextProvider<HeadContext>>
}
}
```
```callout type=note
Something you might have noticed here is the addition of the `HeadContext` type in the
`StaticAppProps`. This is used to capture HTML that should be written into the `<head>` element,
rather than the `<body>`. I'll get to that in the next section.
```
With the `StaticApp` component prepared I was then able to render each route using the
[`LocalServerRenderer`] introduced earlier:
```rust
async fn render_route(&self, route: Route) -> String {
// Create the `HeadContext` to capture HTML written to the <head>.
let head = HeadContext::default();
// Create a `LocalServerRenderer` over the `StaticApp` component.
let render = {
let head = head.clone();
LocalServerRenderer::<StaticApp>::with_props(StaticAppProps { route, head })
};
// Render the HTML for the <body> from the `LocalServerRenderer`.
let mut body = String::new();
render.render_to_string(&mut body).await;
// Create a `LocalServerRenderer` over the `HeadRender` component.
let render =
LocalServerRenderer::<HeadRender>::with_props(HeadRenderProps { context: head });
// Render the HTML for the <head>.
let mut head = String::new();
render.render_to_string(&mut head).await;
// Insert the HTML for the <head> and <body> elements into the template.
self.template.render(head, body).await
}
```
The output of this function is a `String` that contains the `index.html` output from Trunk,
with the HTML generated for the given `Route` inserted. This can then be written to a file.
## Page Titles and Metadata {#page-titles-and-metadata}
For each page that I generated, I not only needed to inject HTML into the `<body>`, but also into
the `<head>`. For example, if I did not do this, each page would have the same `<title>`: that which
was originally in the `index.html` input fed to Trunk. Additional elements need to be added to
`<head>` such as [OpenGraph](https://ogp.me/) and other SEO nonsense.
In order to support writing to the `<head>` I use a `<Head>` component. This component uses a Yew
[portal] to render into the `<head>` element. The element is queried from the document using the
[`head()`] function from `gloo::utils` which returns the [`HTMLHeadElement`]. I retrieve the element
in a [`use_effect_once`] and store it in a state variable. This allows me to use `<Head>` during
static rendering with no effect: the effect will not execute until after hydration. This is
important, as the `gloo::utils::head()` function will panic if not run in a browser, and Yew cannot
hydrate a portal.
```rust
#[derive(Properties, PartialEq)]
pub struct HeadProps {
#[prop_or_default]
pub children: Children,
}
#[function_component(Head)]
pub fn head(props: &HeadProps) -> Html {
let head = use_state(|| None::<HtmlHeadElement>);
// If we have the `HeadContext`, then add our HTML to it.
if let Some(head_cxt) = use_context::<HeadContext>() {
head_cxt.append(html! {
<>{props.children.clone()}</>
});
}
{
let head = head.clone();
use_effect_once(move || {
let head_el = gloo::utils::head();
// Remove the elements that were inserted into the <head> by the SSG.
remove_ssg_elements(&head_el);
// Store the <head> tag in the state.
head.set(Some(head_el));
|| ()
})
}
if let Some(head) = &*head {
create_portal(html! { <>{props.children.clone()}</> }, head.clone().into())
} else {
html! {}
}
}
```
This component only handles the insertion of HTML into the `<head>` during normal operation of the
application or after hydration. I need to insert elements directly into the `<head>` tag when
performing static rendering. To enable this, I added a `HeadContext` that contains a `Vec<Html>`.
When I use the `<Head>` component and the `HeadContext` is available, a copy of the HTML that would
be inserted into the `<head>` is also added to the `HeadContext`.
```callout type=tip title="Definition of HeadContext"
Currently the `HeadContext` type just wraps an `Rc<RefCell<Vec<Html>>>`. It provides a function to
add an `Html` to the `Vec`, and another function to clone the `Vec<Html>` out of the `HeadContext`.
The use of `Rc<RefCell<...>>` is fine in this case, as I'm using the `LocalServerRenderer`. However,
the `ServerRenderer::with_props()` constructor requires that the property type is `Send`, which is
not true for `HeadContext`.
If you want to use the `ServerRenderer` type instead of `LocalServerRenderer`, you will probably
want to change the `HeadContext` type to somethine like an `Arc<Mutex<...>>` rather than an
`Rc<RefCell<...>>`.
```
When it comes to rendering the application, I provide the `HeadContext` value in the `StaticApp`
component, and capture any `Html` stored in it. After rendering the contents of the `<body>` a new
`LocalServerRenderer` is created for the `HeadRender` component. This component simply renders the
content of the `HeadContext`. This renderer is then used to render the contents of `<head>`. After
rendering, the HTML is inserted into the `<head>` between two `<script>` tags:
```html
<head>
...
<script id="head-ssg-before"></script>
<!-- Generated HTML goes here -->
<script id="head-ssg-after"></script>
...
</head>
```
This allows me to write code to inject HTML into the `<head>` that will work with both the normal
operation of the site, during static generation, and when hydrating.
Unfortunately, there is a small problem with this approach to inserting elements in to the `<head>`:
Yew cannot hydrate portals.
As I cannot hydrate a [`VPortal`], the portal that I use in the `Head` component will not be
attached to the elements added to the `<head>` during SSG. The result of this is that, when visitors
navigate using the [`BrowserRouter`], those SSG elements will linger, and our new `<head>` elements
will be overshadowed or ignored. This can lead to some confusion.
To deal with this, I added some code that will remove all SSG generated elements in the `<head>`
when the component mounts. When the application hydrates and the `use_effect_once` is executed, the
elements added by the SSG are removed and a reference to the `<head>` is stored in the state. When
the state changes, this causes a re-render of the `<Head>` component, which will create the portal
that adds the components back into the `<head>`, only this time they are controlled by Yew and can
be changed.
The site removes the elements that were added during SSG by simply removing all elements in the
`<head>` tag between the `<script>` tags with the IDs `head-ssg-before` and `head-ssg-after`. I also
remove these marker `<script>` tags as well.
## Copying and Generating Resources {#copying-and-generating-resources}
The remaining tasks for the `site-build` program to complete are as follows:
1. Copy all the resources output by Trunk into the `dist` directory to the `out` directory, and
2. Generate the remaining resources.
The copying of resources is simply a matter of copying everything that the Trunk tool wrote to
the `dist` directory into the `out` directory. This includes the WebAssembly compiled with the
`hydration` feature and the JavaScript glue generated by [`wasm-bindgen`]. The `site-build` tool is
careful to skip the `index.html` file that was used as a template. If it did not, it would overwrite
the file with the same name that was generated during SSG for the `Route::Home` route.
Finally, the tool needs to generate a few resources:
1. The `sitemap.xml` file,
1. The RSS feed (found under [`/feeds/feed.xml`](/feeds/feed.xml)), and
1. The Atom feed (found under [`/feeds/atom.xml`](/feeds/atom.xml)).
The first file is generated by simply building a `String` containing the XML. The last two are
generated by populating the structures from the [rss] and [atom_syndication] crates and then writing
their generated XML to the corresponding files.
# Conclusion
With the site written and prepared for hydration, the markdown parsed and semi-rendered during
compilation, and the `site-build` tool complete and working... I was finally able to get to the
point where the site was being statically generated and any dynamic elements were being provided by
WebAssembly.
At the time of writing this conclusion, the WebAssembly that will be transmitted to the browser is
816 Kb. The Next.js site included a total of 997 kB of JavaScript.
Loading just the home page loads 1.11 MB (465.27 kB transferred). The Next.js version of this site
loaded 1.42 MB (545.68 kB transferred).
For all intents and purposes I'd call the sites the same size. When I first started using
WebAssembly, I was quite surprised at results like this. For a long time I had been under the
impression that WebAssembly typically required very large files.
It is very difficult to profile this site: it does very little other than rendering some HTML. There
are some places where the WebAssembly is noticeably faster than the Next.js version of the site due
to features that are no longer present. For example, the previous version of the site included a
search system that could highlight tokens within a document when rendering it. This added some
overhead to the page generation that is not present in the WebAssembly version.
I think that WebAssembly and the Rust web frameworks have evolved to a point where they are a
suitable replacement for the likes of React for most of my use cases, both personal and
professional. There are some larger applications that I would need to think about before attempting
to implement as I have with this one and others, mostly notably around the volume of content and the
complexity of the components.
In terms of this site, I think I will keep it as it is and work on some more content. I have a few
other posts in the works that I need to finish off.
[Next.js]: https://nextjs.org/
[Rust]: https://www.rust-lang.org/
[WebAssembly]: https://webassembly.org/
[Yew]: https://yew.rs/
[React]: https://react.dev/
[Vue.js]: https://vuejs.org/
[Gatsby]: https://www.gatsbyjs.com/
[Trunk]: https://trunkrs.dev/
[Server-Side Rendering]: https://yew.rs/docs/next/advanced-topics/server-side-rendering
[SSR hydration]: https://yew.rs/docs/next/advanced-topics/server-side-rendering#ssr-hydration
[`Renderer`]: https://docs.rs/yew/0.20.0/yew/struct.Renderer.html
[`ServerRenderer`]: https://docs.rs/yew/0.20.0/yew/struct.ServerRenderer.html
[`LocalServerRenderer`]: https://docs.rs/yew/0.20.0/yew/struct.LocalServerRenderer.html
[site-build]: https://github.com/BlakeRain/blakerain.com/blob/main/src/bin/site-build.rs
[postcss]: https://postcss.org/
[enum-iterator]: https://docs.rs/enum-iterator/1.4.1/enum_iterator/trait.Sequence.html
[`Sequence`]: https://docs.rs/enum-iterator/latest/enum_iterator/trait.Sequence.html
[`all`]: https://docs.rs/enum-iterator/1.4.1/enum_iterator/fn.all.html
[`Router`]: https://docs.rs/yew-router/0.17.0/yew_router/router/struct.Router.html
[`render_to_string`]: https://docs.rs/yew/0.20.0/yew/struct.ServerRenderer.html#method.render_to_string
[`BrowserRouter`]: https://docs.rs/yew-router/0.17.0/yew_router/router/struct.BrowserRouter.html
[`BrowserHistory`]: https://docs.rs/gloo/0.10.0/gloo/history/struct.BrowserHistory.html
[`MemoryHistory`]: https://docs.rs/yew-router/0.17.0/yew_router/history/struct.MemoryHistory.html
[portal]: https://yew.rs/docs/advanced-topics/portals
[`head()`]: https://docs.rs/gloo/0.10.0/gloo/utils/fn.head.html
[`HTMLHeadElement`]: https://docs.rs/web-sys/0.3.64/web_sys/struct.HtmlHeadElement.html
[`use_effect_once`]: https://docs.rs/yew-hooks/0.2.0/yew_hooks/fn.use_effect_once.html
[`VPortal`]: https://docs.rs/yew/0.20.0/yew/virtual_dom/struct.VPortal.html
[`Rendered::hydrate`]: https://docs.rs/yew/0.20.0/yew/struct.Renderer.html#method.hydrate
[`PhantomComponent`]: https://docs.rs/yew/0.20.0/yew/html/struct.PhantomComponent.html
[include_dir]: https://docs.rs/include_dir/0.7.3/include_dir/index.html
[`VRaw`]: https://docs.rs/yew/0.20.0/yew/virtual_dom/struct.VRaw.html
[`Event`]: https://docs.rs/pulldown-cmark/0.9.3/pulldown_cmark/enum.Event.html
[`Tag`]: https://docs.rs/pulldown-cmark/0.9.3/pulldown_cmark/enum.Tag.html
[`number of locals`]: https://github.com/bytecodealliance/wasm-tools/blob/03d21b8d9f0dd29441c5de4d6a2fc1505a9fd0d5/crates/wasmparser/src/validator/operators.rs#L3425C10-L3425C10
[Firefox]: https://github.com/mozilla/gecko-dev/blob/132ffbfd6842e5ecd3813673c24da849d3c9acf8/js/src/wasm/WasmConstants.h#L1097
[Chrome]: https://github.com/v8/v8/blob/3c8f523f939680fb5f8ba48ee6dc80adfb22fe83/src/wasm/wasm-limits.h#L51
[postcard]: https://docs.rs/postcard/1.0.6/postcard/index.html
[`VTag::add_attribute()`]: https://docs.rs/yew/0.20.0/yew/virtual_dom/struct.VTag.html#method.add_attribute
[Allocating Memory for DMA in Linux]: /blog/allocating-memory-for-dma-in-linux
[Moving to Mastodon]: /blog/moving-to-mastodon
[Overlays with Custom Widgets in GTK]: /blog/overlays-with-custom-widgets-in-gtk
[`wasm-bindgen`]: https://docs.rs/wasm-bindgen/latest/wasm_bindgen/
[rss]: https://docs.rs/rss/2.0.6/rss/index.html
[atom_syndication]: https://docs.rs/atom_syndication/0.12.2/atom_syndication/

View File

@ -5,9 +5,10 @@ tags:
- python
- javascript
- react
excerpt: In this post we look at an updated implementation of the site search feature.
published: 2020-11-27T18:40:09.000Z
cover: /content/Selection_2059.png
cover: /content/updated-site-search/cover.png
excerpt: |
In this post we look at an updated implementation of the site search feature.
---
I decided to re-visit the search functionality that I [had added](http://localhost:2368/blog/adding-search-to-ghost/) to this site to provide a slightly better search mechanism. I had three objectives that I wanted to achieve:
@ -24,7 +25,7 @@ The new search comprises two main components: a front-end interface and a back-e
In this post I go into some detail of how the search is implemented. All the source code is available in the GitHub repository for this [blog](https://github.com/BlakeRain/blakerain.com).
### Using a Prefix Tree
# Using a Prefix Tree
One of my goals for the new search is that it should be interactive, and quite fast. That means it must quickly give the user reasonably useful results. Moreover, as I wanted to simplify the implementation, I would like to maintain very few dependencies.
@ -84,7 +85,7 @@ Searching for occurrences from this node, the first leaf we reach is for the wor
Building the results in this way allows us to quickly ascertain that words starting with the two letters `"be"` can be found in _Document 2_ primarily (there are six occurrences) and in _Document 1_, where we find one occurence.
### Generating the Search Data
# Generating the Search Data
To build the prefix tree I decided to create a GitHub action. This action would be configured to be run at a certain interval (such as every hour) to regenerate the search data.
@ -118,7 +119,7 @@ Once the search data has been built and the file has been generated, the GitHub
This data is then loaded and parsed by the search front-end.
### Search Front-End
# Search Front-End
The search interface is a small amount of React code that is complied along with the customized Casper theme for the site. The interface loads and parses the search data from S3. For profiling it outputs a console message indicating how many posts and trie nodes were loaded from the search data, and the time it took:
@ -134,7 +135,7 @@ When a link is clicked in the search results, the page opened. The link contains
![](/content/updated-site-search/Selection_2056.png)
### Conclusion
# Conclusion
I find this search implementation to be far simpler to maintain and use. We use a similar search system in our internal compliance management system at [Neo](https://neotechnologiesltd.com/). This removes the reliance on a secondary server that was solely used to service search queries. This leads to a cleaner approach that will also simplify moving the site to a CDN.
@ -142,11 +143,14 @@ Something to note is that this search implementation whilst very simple to i
As mentioned earlier, you can find all the source in the GitHub repository:
<Bookmark
url="https://github.com/BlakeRain/blakerain.com"
title="BlakeRain/blakerain.com"
description="Repository for the Ghost theme of my blog. Contribute to BlakeRain/blakerain.com development by creating an account on GitHub."
author="BlakeRain"
publisher="GitHub"
thumbnail="https://avatars2.githubusercontent.com/u/8750438?s=400&v=4"
icon="https://github.githubassets.com/favicons/favicon.svg" />
```bookmark
title: "BlakeRain/blakerain.com"
url: "https://github.com/BlakeRain/blakerain.com"
description: |
Repository for the Ghost theme of my blog. Contribute to BlakeRain/blakerain.com development by
creating an account on GitHub.
author: BlakeRain
publisher: GitHub
thumbnail: "https://avatars2.githubusercontent.com/u/8750438?s=400&v=4"
icon: "https://github.githubassets.com/favicons/favicon.svg"
```

View File

@ -1,10 +1,6 @@
---
title: Disclaimer
published: 2021-01-14T22:53:12.000Z
search: false
seo:
index: false
follow: false
---
This is a personal website and blog. Any views or opinions represented on this site are personal and belong solely to
@ -31,39 +27,3 @@ This site disclaimer is subject to change at any time.
Any downloadable file, including but not limited to source code, PDFs, documents, and images, is provided at the user's
own risk. The owner will not be liable for any losses, injuries, or damages resulting from a corrupted or damaged file.
## Comments
Comments are welcome. However, the site owner reserves the right to edit or delete any comments submitted to this site
without notice due to:
1. Comments deemed to be spam or questionable spam.
1. Comments including profanity.
1. Comments containing language or concepts that could be deemed offensive.
1. Comments containing hate speech, credible threats, or direct attacks on an individual or group.
1. Any other reason not covered in the options above.
The site owner is not responsible for the content in comments.
## Analytics
Simple analytics are gathered when viewing pages on this site. These analytics does not use any form of cookies or
third-party analytics service. No personal data or IP addresses are collected. The data that is collected by the
analytics on this site is detailed below.
1. The path to the page being viewed. As an example, this disclaimer page has the path `/disclaimer`.
1. The user-agent string. This shows the browser used, the operating system, and the type of device.
1. The dimensions of the screen of the device and the viewport in which the site is rendered.
1. The referrer information, which allows the site owner to ascertain how visitors were directed to this site.
1. The time zone of the browser. This information is used to ascertain which countries visitors are from.
1. The amount of time spent on the page.
1. How far down the page you have scrolled.
The above can be verified via inspection of the [source
code](https://github.com/BlakeRain/blakerain.com/blob/main/components/Analytics.tsx). The data that will have been
collected for your current view of this disclaimer page is as follows:
<AnalyticsInformation />
The analytics data is stored in a database hosted by AWS in an EU region. No data is transferred outside of the EU.
At any point the information captured by the analytics may be changed in compliance with the [GDPR](https://gdpr-info.eu/).

View File

@ -2,141 +2,87 @@
# tags.yaml
#
getting-started:
slug: getting-started
name: Getting Started
visibility: public
haskell:
slug: haskell
name: Haskell
visibility: public
description: Posts that feature the Haskell programming language.
reviews:
slug: reviews
- slug: reviews
name: Reviews
visibility: public
description: Reviews of books or other articles
electronics:
slug: electronics
name: Electronics
visibility: public
description: Posts that feature electronic circuits.
javascript:
slug: javascript
- slug: javascript
name: JavaScript
visibility: public
description: Posts that feature the JavaScript scripting language.
python:
slug: python
- slug: python
name: Python
visibility: public
description: Posts that feature the Python scripting language
ghost-tag:
slug: ghost-tag
- slug: ghost-tag
name: Ghost
visibility: public
description: Posts related to my use of the Ghost CMS
gtk:
slug: gtk
- slug: gtk
name: GTK
visibility: public
description: Posts that feature the GTK graphical user interface toolkit.
aws:
slug: aws
- slug: aws
name: AWS
visibility: public
description: Posts that relate to Amazon Web Services
linux:
slug: linux
- slug: linux
name: Linux
visibility: public
description: Posts that feature the Linux operating system.
cairo:
slug: cairo
name: Cairo
visibility: public
description: Posts that feature the Cairo vector-based graphics library.
cpp:
slug: cpp
- slug: cpp
name: C++
visibility: public
description: Posts that feature the C++ programming language.
yesod:
slug: yesod
name: Yesod
visibility: public
description:
Posts which feature the Yesod web application development framework
for the Haskell programming language.
ethernet:
slug: ethernet
- slug: ethernet
name: Ethernet
visibility: public
description: Posts that relate to Ethernet networking
react:
slug: react
- slug: react
name: React
visibility: public
description: Posts that make use of the React JavaScript library.
gc:
slug: gc
- slug: gc
name: GC
visibility: public
description: Posts that relate to garbage collection and memory management.
sqlite:
slug: sqlite
name: SQLite
visibility: public
description: Posts that feature the SQLite embedded relational database.
chakracore:
slug: chakracore
- slug: chakracore
name: ChakraCore
visibility: public
description: Posts that feature the ChakraCore JavaScript engine.
pci:
slug: pci
- slug: pci
name: PCI
visibility: public
description: Articles related to programming PCI express devices.
usb:
slug: usb
name: USB
visibility: public
description: Posts that relate to working with the Universal Serial Bus
intel-i350:
slug: intel-i350
- slug: intel-i350
name: Intel i350
visibility: public
description: Blog posts about programming the Intel i350 NIC and related models.
rust:
slug: rust
- slug: rust
name: Rust
visibility: public
description: Blog posts related to the Rust programming language
mastodon:
slug: mastodon
- slug: mastodon
name: Mastodon
visibility: public
description: Posts that feature the Mastodon social network.
- slug: yew
name: Yew
visibility: public
description: Posts that relate to the Yew web framework.

View File

@ -1 +0,0 @@
<mxfile host="Electron" modified="2022-12-31T11:44:54.919Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/20.7.4 Chrome/106.0.5249.199 Electron/21.3.3 Safari/537.36" etag="vdH-aiIYMUC2v1GbkQq7" version="20.7.4" type="device"><diagram id="vnPnQphzv46ZmVF16kpH" name="Page-1">7Vlbb+I4FP41SLsPi3KDMI+ES6dSd1VNR+rMEzKJSTx14shxgMyv3+PEIRennaJtd1oVQMLns33sHJ/zfSGM7EV8vOIojf5mAaYjywiOI3s5sizbdEz4kkhRIaY7cyok5CRQWAPckZ9YgYZCcxLgrDNQMEYFSbugz5IE+6KDIc7ZoTtsx2h31RSFWAPufER19J4EIqpQ1zIa/DMmYaRWtmaqI0b1WAVkEQrYoQXZq5G94IyJqhUfF5jK4NVhqeatH+k97YvjRDxnwurO/v4Pd+9/fvlxn3/aevP99ewvUx3GHtFcXfA8QbQQxM/UtkVRhyJlJBFlOCcefGC5hTGaQM9CWmNr0gP6ttsFTN2SPrpA33a7gNl3b/bWN/sbbAGa1XFv9NY3WhuEj+2xXFCS4MUp8QwAQ44CAieyYJRxwBKWQPS8SMQULBOah4gIfJciX0b1AFUD2I4lQqW+adW2Crz0CsktEKzFlY/yJDBf7XF1INUYSlGake1pFsd+zjOyx19wVjmXKORhKtvxMZQlO0aHzBmHnOVpuf1rWGuwdwPNjU9ZHmwQFdKR4OwB1xc6smx4r2XueTtCaS8Ae8whpxCdUxJK/4LJ5ZCyKN6VHiEqJAlvSmtpGyoSQ0sEKItwoC5JLwVVHXJVfGxBqjSuMIux4AUMUb2OqtKiax6akp/WWNQud0OBSNFMePLclCI0VDWeU5kTrTJXFMflgb9E5U+18sYBEJ8yGRcRCxlQwapBPUiDJDhFvRlzw+Rplpn5AwtRqFRGuWDd3IcN8+Kbml8a36UBVabM5bHduSza1i3mBGJbFsHwqT8ZyIzl3MdPDVTqgniIn3JoV+NkuJ7MLI4pElB9XUl68TzR0gSlRDvb7AELP1KBG+TxQS4f4vNBTtd5vTOsZNqBFfrgEObqoKkPq8lZB4ewISXqzzYHZpu92Y/rwGO81dcH6Fu7s5XhtPqWBJhbkJKHE1llPUqFOUtjsjDdIRLela8+Q9b0e4O2mN6yjCj3WyYEi3/Jzz5Oyrpr1/KvNAtlaRWOHTnKfQwLD8dVXVayA5KVDQkQRfE2QOfX/PP5vy8AM10A3JnO/zX24mU9+y30fCTixM7QrsjZnSizIWdpFC2jT80nnj+RftvVuUSfASWLubyXb+4nSmxNZFBfRQysdykGliYGgpMwhEheBOEiCBdBOEMQJtO3JQju7xSEul2xuPUfBKF342+9G0GwnykI1psSBFsTBPnAarMn+AA/I6fl7/gtqMM0lC2B5MODi1S8H6lwli50nicV8HK8Tx9GKoIiQTELtq8pFo7xtsRiohXxR3+44zyTvs03Rd/60/nPX7/eAjK/vdbp+woJfECF3vFHuh9vKXrAHJFk7LP4zwvJvyOSX6+d9cw7j+S9hWlPph+G5FFKNqFK/1fk+dn/RfNgNv8Mln2t/1ft1b8=</diagram></mxfile>

48
index.html Normal file
View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="referer" content="no-referer-when-downgrade" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" sizes="76x76" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#12304c" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link data-trunk rel="inline" type="css" href="public/google-fonts.css" />
<meta name="msapplication-TileColor" content="#12304e" />
<meta name="theme-color" content="#12304e" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Blake Rain",
"url": "https://blakerain.com"
}
</script>
<script id="head-ssg-before"></script>
<script id="head-ssg-after"></script>
<link data-trunk rel="rust" data-bin="site" data-wasm-opt="z" />
<link data-trunk rel="inline" type="css" href="target/main.css" />
<link data-trunk rel="copy-dir" href="public/media" />
<link data-trunk rel="copy-dir" href="public/content" />
<link data-trunk rel="copy-file" href="public/android-chrome-72x72.png" />
<link data-trunk rel="copy-file" href="public/android-chrome-144x144.png" />
<link data-trunk rel="copy-file" href="public/apple-touch-icon.png" />
<link data-trunk rel="copy-file" href="public/browserconfig.xml" />
<link data-trunk rel="copy-file" href="public/favicon-16x16.png" />
<link data-trunk rel="copy-file" href="public/favicon-32x32.png" />
<link data-trunk rel="copy-file" href="public/favicon.ico" />
<link data-trunk rel="copy-file" href="public/favicon.png" />
<link data-trunk rel="copy-file" href="public/mstile-150x150.png" />
<link data-trunk rel="copy-file" href="public/robots.txt" />
<link data-trunk rel="copy-file" href="public/safari-pinned-tab.svg" />
<link data-trunk rel="copy-file" href="public/site.webmanifest" />
</head>
</html>

View File

@ -1,388 +0,0 @@
// Website analytics API client.
//
// This module contains the functionality that interfaces with the website analytics.
function getDaysInMonth(year: number, month: number): number {
const last = new Date(0);
last.setFullYear(year, 1 + month, 0);
last.setHours(0, 0, 0, 0);
return last.getDate();
}
export function getAnalyticsURL(path: string): string {
const host = process.env.ANALYTICS_HOSTNAME || "https://pv.blakerain.com";
return host + "/" + path;
}
// The session token where we store the authentication token.
const SESSION_TOKEN_NAME = "blakerain:analytics:token";
// Retrieve the authentication token from the session storage (if we have one).
export function getSessionToken(): string | null {
if (typeof window !== "undefined") {
return sessionStorage.getItem(SESSION_TOKEN_NAME);
} else {
return null;
}
}
// Store the authentication token in the session storage.
export function setSessionToken(token: string) {
sessionStorage.setItem(SESSION_TOKEN_NAME, token);
}
/// Given a username and password, attempt to authenticate with the API.
///
/// If this succeeds, it will return the authentication token from the promise. This token can then be used as the
/// `Bearer` token in an `Authorization` header for subsequent requests.
export const authenticate = async (
username: string,
password: string
): Promise<string> => {
const res = await fetch(getAnalyticsURL("api/auth/signin"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
const result = (await res.json()) as { error?: string; token?: string };
if (result.error) {
return Promise.reject(result.error);
}
if (result.token) {
return result.token;
}
return Promise.reject("Expected to receive authentication token (or error)");
};
/// The analytics data for a day of a week
export interface WeekView {
/// The year.
year: number;
/// The ISO week number.
week: number;
/// The day of the week.
day: number;
/// The number of views on this day.
count?: number;
/// The average amount of scroll distance on this day.
scroll?: number;
/// The average visit duration on this day.
duration?: number;
}
// Remapping from day-of-week index in Rust to JavaScript.
const DAYS_REMAP = [6, 0, 1, 2, 3, 4, 5];
/// Get the week view for the given path, year and ISO week number.
///
/// The `path` argument is the path to the URL we want to view. If this path is `site` (does not start with a `/`), then
/// we will see the accumulated data for the entire site.
///
/// This will return an array of `WeekView` for each day in the week. For days on which no data has been recorded, this
/// will fill in empty `WeekView` records. The returned array is sorted by the day-of-week.
export const getWeekViews = async (
token: string,
path: string,
year: number,
week: number
): Promise<WeekView[]> => {
const res = await fetch(getAnalyticsURL("api/views/week"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
path,
year: year,
week: week,
}),
});
var weeks: WeekView[] = await res.json();
// Add in any `WeekView` records that do not exist, to ensure there are no gaps.
for (let day = 0; day < 7; ++day) {
var found = false;
for (let index = 0; index < weeks.length; ++index) {
if (weeks[index].day === day) {
found = true;
break;
}
}
if (!found) {
weeks.push({ year, week, day });
}
}
// Remap days from Rust to JavaScript.
weeks.forEach((week) => {
week.day = DAYS_REMAP[week.day];
});
return weeks.sort((a, b) => a.day - b.day);
};
/// The analytics data for a day of a month.
export interface MonthView {
/// The year.
year: number;
/// The month.
month: number;
/// The day of the month.
day: number;
/// The number of views on this day.
count?: number;
/// The average scroll distance on this day.
scroll?: number;
/// The average visit duration on this day.
duration?: number;
}
/// Get the month view for the given path, year and month.
///
/// The `path` argument is the path to the URL we want to view. If this path is `site` (does not start with a `/`),
/// then we will see the accumulated data for the entire site.
///
/// This will return an array of `MonthView` for each day in the month. For days on which no data has been recorded,
/// this will fill in empty `MonthView` records. The returned array is sorted by day.
export const getMonthViews = async (
token: string,
path: string,
year: number,
month: number
): Promise<MonthView[]> => {
const res = await fetch(getAnalyticsURL("api/views/month"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
path,
year: year,
month: 1 + month,
}),
});
const days = getDaysInMonth(year, month);
var months: MonthView[] = await res.json();
// Fill in any missing days with empty records.
for (let day = 1; day <= days; ++day) {
var found = false;
for (let index = 0; index < months.length; ++index) {
if (months[index].day === day) {
found = true;
break;
}
}
if (!found) {
months.push({ year, month, day, count: 0, scroll: 0, duration: 0 });
}
}
return months.sort((a, b) => a.day - b.day);
};
/// The total number of views of a specific page.
export interface PageCount {
page: string;
count: number;
}
/// Get the total number of views of pages over the given week.
export const getWeekPageCount = async (
token: string,
year: number,
week: number
): Promise<PageCount[]> => {
const res = await fetch(getAnalyticsURL("api/pages/week"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
year,
week,
}),
});
return await res.json();
};
/// Get the total number of views of pages over the given month.
export const getMonthPageCount = async (
token: string,
year: number,
month: number
): Promise<PageCount[]> => {
const res = await fetch(getAnalyticsURL("api/pages/month"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
year,
month: month + 1,
}),
});
return await res.json();
};
/// Represents the number of visits by a given browser on a certain day.
export interface BrowserDataItem {
day: number;
count?: number;
}
/// A mapping from a browser name to a set of data points for that browser over a number of days (sorted by day).
export type BrowserData = { [key: string]: BrowserDataItem[] };
/// Represents the recorded browser activity over a week.
export interface BrowsersWeek {
/// The year.
year: number;
/// The ISO week.
week: number;
/// Mapping from a browser to the data for that browser (sorted by day).
browsers: BrowserData;
}
/// Get the view counts for various browsers over the given week.
export const getBrowsersWeek = async (
token: string,
year: number,
week: number
): Promise<BrowsersWeek> => {
const res = await fetch(getAnalyticsURL("api/browsers/week"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
year,
week,
}),
});
var data: BrowsersWeek = {
year,
week,
browsers: {},
};
const json: {
browser: string;
year: number;
week: number;
day: number;
count: number;
}[] = await res.json();
json.forEach((obj) => {
const day = DAYS_REMAP[obj["day"]];
const count = obj["count"];
const browser = obj["browser"];
if (browser in data.browsers) {
data.browsers[browser].push({ day, count });
} else {
data.browsers[browser] = [{ day, count }];
}
});
for (let day = 0; day < 7; ++day) {
Object.keys(data.browsers).forEach((browser) => {
const found = data.browsers[browser].find((item) => item.day === day);
if (!found) {
data.browsers[browser].push({ day });
}
});
}
Object.keys(data.browsers).forEach((browser) => {
data.browsers[browser].sort((a, b) => a.day - b.day);
});
return data;
};
/// Represents the recorded browser activity over a month.
export interface BrowsersMonth {
/// The year.
year: number;
/// The month.
month: number;
/// Mapping from a browser to the data for that browser (sorted by day).
browsers: BrowserData;
}
/// Get the view counts for various browsers over the given month.
export const getBrowsersMonth = async (
token: string,
year: number,
month: number
): Promise<BrowsersMonth> => {
const res = await fetch(getAnalyticsURL("api/browsers/month"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token, year, month: 1 + month }),
});
var data: BrowsersMonth = {
year,
month,
browsers: {},
};
const json: {
browser: string;
year: number;
week: number;
day: number;
count: number;
}[] = await res.json();
json.forEach((obj) => {
const day = obj["day"];
const count = obj["count"];
const browser = obj["browser"];
if (browser in data.browsers) {
data.browsers[browser].push({ day, count });
} else {
data.browsers[browser] = [{ day, count }];
}
});
const days = getDaysInMonth(year, month);
for (let day = 1; day <= days; ++day) {
Object.keys(data.browsers).forEach((browser) => {
const found = data.browsers[browser].find((item) => item.day === day);
if (!found) {
data.browsers[browser].push({ day });
}
});
}
Object.keys(data.browsers).forEach((browser) => {
data.browsers[browser].sort((a, b) => a.day - b.day);
});
return data;
};

View File

@ -1,323 +0,0 @@
import { promises as fs } from "fs";
import path from "path";
import { TagId } from "./tags";
import { serialize } from "next-mdx-remote/serialize";
import { MDXRemoteSerializeResult } from "next-mdx-remote";
import remarkEmoji from "remark-emoji";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeImageSize from "rehype-img-size";
import matter from "gray-matter";
import { GitLogEntry, loadFileRevisions } from "./git";
import {
rehypeAddPaths,
rehypeWrapFigures,
remarkUnwrapImages,
} from "./plugins";
/// Information about a document
export interface DocInfo {
/// The slug used to form the URL for the document.
slug: string;
/// The rendered title for the document.
title: string;
/// Any excerpt given in indices or at the start of a document.
excerpt: string | null;
/// The ISO-8601 date string on which the document was published.
published: string;
}
/// Interface for something that has a number of tags.
export interface Tagged {
/// The tags (if any) for this object.
tags: TagId[];
}
/// Summary information about a blog post.
///
/// This extends both `DocInfo` for summary information about the document and `Tagged` to associate tags with a blog
/// post.
export interface PostInfo extends DocInfo, Tagged {
/// The amount of time it will roughly take to read the blog post.
readingTime: number;
/// URL for the cover image (if there is one).
coverImage: string | null;
}
/// A fully deserialized blog post.
///
/// This interface extends `PostInfo` to include the content of the blog post.
export interface Post extends PostInfo {
/// The content of the blog post, as parsed by MDX.
content: MDXRemoteSerializeResult;
/// Any pre-amble data for the blog post.
preamble: PostPreamble;
/// The git history of changes made to this blog post.
history: GitLogEntry[];
}
/// A full deserialized page.
///
/// This interface extends `DocInfo` to include the contents of the page.
export interface Page extends DocInfo {
/// The content of the page, as parsed by MDX.
content: MDXRemoteSerializeResult;
/// Any pre-amble data for the page.
preamble: PagePreamble;
}
/// Represents general document preamble.
///
/// Preambles are provided using YAML in the frontispiece of a markdown document. This structure represents the basic
/// information extracted from the preamble for all documents (posts or pages).
export interface Preamble {
/// The title of the document (if any).
title?: string;
/// When the document was published (if any, as an ISO-8601 string).
published?: string;
/// The excerpt for the document (if any).
excerpt?: string;
/// Whether to include the git history of this document.
history?: boolean;
/// Should we index this post for search (default is 'true').
search?: boolean;
}
/// Preamble specific to a blog post.
export interface PostPreamble extends Preamble {
/// The cover image URL.
cover?: string;
/// The IDs (slugs) of the tags for this post.
tags?: TagId[];
}
/// Preamble specific to a page.
export interface PagePreamble extends Preamble {
/// SEO settings for the page.
seo?: {
/// Whether to include this page for indexing.
index?: boolean;
/// Whether robots should follow links from this page.
follow?: boolean;
};
}
const WORD_RE = /[a-zA-Z0-9_-]\w+/;
// Roughly count the words in a source string.
function countWords(source: string): number {
return source.split(/\s+/).filter((word) => WORD_RE.exec(word)).length;
}
// Load the document source for a given path.
//
// This function will load the source at the given path and split out any front-matter.
export async function loadDocSource<P extends Preamble>(
doc_path: string
): Promise<{ preamble: P; source: string }> {
const source = await fs.readFile(doc_path, "utf-8");
const { content, data } = matter(source, {});
return {
preamble: data as P,
source: content,
};
}
// Load a document from the given path.
//
// This function will load the document from the given path using `loadDocSource`. It will then parse the contents of
// the document, returning the components needed to produce the various interfaces such as a `Post` or `Page`.
//
// 1. Count the number of words in the document.
// 2. Use MDX to parse the contents of the document, including our chosen remark and rehype plugins.
// 3. Load and parse the git history for the file (unless instructed otherwise).
async function loadDoc<P extends Preamble>(
doc_path: string
): Promise<{
preamble: P;
source: string;
wordCount: number;
content: MDXRemoteSerializeResult;
history: GitLogEntry[];
}> {
const { preamble, source } = await loadDocSource<P>(doc_path);
return {
preamble,
source,
wordCount: countWords(source),
content: await serialize(source, {
scope: preamble as Record<string, any>,
mdxOptions: {
development: process.env.NODE_ENV === "development",
remarkPlugins: [remarkUnwrapImages, remarkGfm, remarkEmoji],
rehypePlugins: [
rehypeSlug,
rehypeAutolinkHeadings,
[rehypeImageSize as any, { dir: "public" }],
rehypeWrapFigures,
rehypeAddPaths,
],
},
}),
history:
preamble.history !== false ? await loadFileRevisions(doc_path) : [],
};
}
// Parse the date received in some preamble.
//
// Dates can be stored either as strings or a `Date` object (due to helpful YAML parsing), or be missing. In all three
// cases we try to extract an ISO-8601 string that we can serialize to JSON.
function processDate(date: string | Date | undefined): string {
if (typeof date === "string") {
return date;
} else if (typeof date === "undefined") {
return "2020-01-01T09:00:00.000Z";
} else {
return date.toISOString();
}
}
// Parse any date-like objects found in the given object.
//
// This goes some way to ensure that the `Record` doesn't contain any `Date` objects, which we cannot serialize to JSON.
// Instead all dates should be stored as ISO-8601 strings.
function processDates(obj: Record<string, any>): Record<string, any> {
Object.keys(obj).forEach((key) => {
let value = obj[key];
if (value instanceof Date) {
obj[key] = value.toISOString();
} else if (typeof value === "object") {
obj[key] = processDates(value);
}
});
return obj;
}
// Given a path to a document and some preamble, build the `DocInfo`.
//
// This function constructs the `DocInfo` interface using the given data:
//
// 1. The `slug` of the document is the document's filename without the '.md' extension.
// 2. The title is "Untitled" unless a title is provided in the preamble.
// 3. The excerpt is extracted from the preamble (if there is any).
// 4. The `published` date string is retrieved from the preamble (if there is any).
export function extractDocInfo(
filename: string,
preamble: PagePreamble
): DocInfo {
return {
slug: path.basename(filename).replace(".md", ""),
title: preamble.title || "Untitled",
excerpt: preamble.excerpt || null,
published: processDate(preamble.published),
};
}
// Given a path to a document and some preamble, build the `PostInfo`.
//
// This function builds the `PostInfo` by first building the `DocInfo` that `PostInfo` extends using the
// `extractDocInfo` function defined above. This function then extracts the following:
//
// 1. The `coverImage` is extracted from the preamble if one is present. A `/` is prepended to the cover image path if
// one is not already present.
// 2. The `readingTime` is "calculated" by dividing the number of words in the document by 200.
// 3. The `tags` are extracted from the preamble if any are present.
function extractPostInfo(
filename: string,
preamble: PostPreamble,
wordCount: number
): PostInfo {
const obj = extractDocInfo(filename, preamble) as PostInfo;
obj.coverImage = preamble.cover || null;
if (typeof obj.coverImage === "string" && !obj.coverImage.startsWith("/")) {
obj.coverImage = "/" + obj.coverImage;
}
obj.readingTime = Math.trunc(wordCount / 200);
obj.tags = preamble.tags || [];
return obj;
}
// --------------------------------------------------------------------------------------------------------------------
/// Load a `Page` from the given path.
export async function loadPage(doc_path: string): Promise<Page> {
const { preamble, content } = await loadDoc<PagePreamble>(doc_path);
return {
...extractDocInfo(doc_path, preamble),
preamble: processDates(preamble),
content,
};
}
/// Load the slugs for all pages in the site.
export async function loadPageSlugs(): Promise<string[]> {
const pagesDir = path.join(process.cwd(), "content", "pages");
const filenames = await fs.readdir(pagesDir);
return filenames.map((filename) =>
path.basename(filename).replace(".md", "")
);
}
/// Load a `Page` with the given slug.
export async function loadPageWithSlug(slug: string): Promise<Page> {
const pagePath = path.join(process.cwd(), "content", "pages", slug + ".md");
return await loadPage(pagePath);
}
// --------------------------------------------------------------------------------------------------------------------
/// Load a `Post` from the given path.
export async function loadPost(doc_path: string): Promise<Post> {
const { preamble, wordCount, content, history } = await loadDoc<PostPreamble>(
doc_path
);
return {
...extractPostInfo(doc_path, preamble, wordCount),
preamble: processDates(preamble),
history,
content,
};
}
/// Load the slugs for all posts in the site.
export async function loadPostSlugs(): Promise<string[]> {
const postsDir = path.join(process.cwd(), "content", "posts");
const filenames = await fs.readdir(postsDir);
return filenames.map((filename) =>
path.basename(filename).replace(".md", "")
);
}
/// Load all `PostInfo` for the posts in the site.
///
/// This is used to build the index of blog posts, where only some summary information of a post is required (as encoded
/// by the `PostInfo` interface). This function will also sort the results by descending published date.
export async function loadPostInfos(): Promise<PostInfo[]> {
const postsDir = path.join(process.cwd(), "content", "posts");
const filenames = await fs.readdir(postsDir);
const posts = await Promise.all(
filenames.map(async (filename) => {
const { preamble, source } = await loadDocSource<PostPreamble>(
path.join(postsDir, filename)
);
return extractPostInfo(filename, preamble, countWords(source));
})
);
return posts.sort(
(a, b) => new Date(b.published).getTime() - new Date(a.published).getTime()
);
}
/// Load a `Post` with the given slug.
export async function loadPostWithSlug(slug: string): Promise<Post> {
const postPath = path.join(process.cwd(), "content", "posts", slug + ".md");
return await loadPost(postPath);
}

View File

@ -1,68 +0,0 @@
import { promises as fs } from "fs";
import path from "path";
import { Feed, Item } from "feed";
import { loadPostInfos } from "../lib/content";
const BASE_URL = "https://www.blakerain.com";
/// Create the various RSS and Atom feeds for all blog posts in the site.
export async function generateFeeds() {
const now = new Date();
const feed = new Feed({
title: "Blake Rain",
description: "Feed of blog posts on the website of Blake Rain",
id: `${BASE_URL}/`,
link: `${BASE_URL}/`,
language: "en",
image: `${BASE_URL}/media/logo-text.png`,
favicon: `${BASE_URL}/favicon.png`,
copyright: `All Rights Reserved ${now.getFullYear()}, Blake Rain`,
updated: now,
feedLinks: {
json: `${BASE_URL}/feeds/feed.json`,
atom: `${BASE_URL}/feeds/atom.xml`,
rss2: `${BASE_URL}/feeds/feed.xml`,
},
author: {
name: "Blake Rain",
email: "blake.rain@blakerain.com",
link: `${BASE_URL}/about`,
},
});
const posts = await loadPostInfos();
for (const post of posts) {
const post_url = `${BASE_URL}/blog/${post.slug}`;
const item: Item = {
title: post.title,
id: post_url,
link: post_url,
date: new Date(post.published),
author: [
{
name: "Blake Rain",
email: "blake.rain@blakerain.com",
link: `${BASE_URL}/about`,
},
],
};
if (post.excerpt) {
item.description = post.excerpt;
}
if (post.coverImage) {
item.image = `${BASE_URL}${post.coverImage}`;
}
feed.addItem(item);
}
const feedsDir = path.join(process.cwd(), "public", "feeds");
await fs.mkdir(feedsDir, { recursive: true });
await fs.writeFile(path.join(feedsDir, "feed.xml"), feed.rss2());
await fs.writeFile(path.join(feedsDir, "feed.json"), feed.json1());
await fs.writeFile(path.join(feedsDir, "atom.xml"), feed.atom1());
}

View File

@ -1,52 +0,0 @@
// A utility module for extracting git history
//
// This module exports some functions that can be used to extract the history for a given path in the repository. This
// allows us to include a revision history with each file (e.g. a blog post).
//
// The trick here is to get git to format it's output as JSON so we can parse it. However, git will not escape any
// inverted commas (`"`) in commit messages. For this reason we use a placeholder (currently `^^^^`) for an inverted
// comma, and later perform the escaping ourselves (see the `parseGitLogEntries` function).
import util from "util";
import { exec } from "child_process";
const exec_async = util.promisify(exec);
const GIT_LOG_FORMAT =
"{^^^^hash^^^^:^^^^%H^^^^,^^^^abbreviated^^^^:^^^^%h^^^^,^^^^author^^^^:^^^^%an^^^^,^^^^date^^^^:^^^^%aI^^^^,^^^^message^^^^:^^^^%s^^^^}";
export interface GitLogEntry {
hash: string;
abbreviated: string;
author: string;
date: string;
message: string;
}
function parseGitLogEntries(source: string): GitLogEntry[] {
return JSON.parse(
"[" +
source
.replaceAll("`", "'")
.replaceAll('"', '\\"')
.replaceAll("^^^^", '"')
.split("\n")
.join(",") +
"]"
);
}
/// Load the revisions history of the given file.
export async function loadFileRevisions(
file_path: string
): Promise<GitLogEntry[]> {
let { stdout } = await exec_async(
`git log --pretty=format:'${GIT_LOG_FORMAT}' "${file_path}"`
);
try {
return parseGitLogEntries(stdout);
} catch (exc) {
console.error("Failed to parse JSON from 'git log' for '" + file_path + "'");
throw exc;
}
}

View File

@ -1,147 +0,0 @@
import { promises as fs } from "fs";
import path from "path";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkMdx from "remark-mdx";
import remarkRehype from "remark-rehype";
import remarkEmoji from "remark-emoji";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeImageSize from "rehype-img-size";
import { Root } from "hast";
import Store from "./search/encoding/store";
import IndexDoc from "./search/document/document";
import IndexBuilder from "./search/index/builder";
import { loadDocSource, Preamble } from "./content";
import { rehypeWrapFigures, remarkUnwrapImages } from "./plugins";
// Create the unified processor that we use to parse markdown into HTML.
function createProcessor() {
return unified()
.use(remarkParse)
.use(remarkMdx)
.use(remarkUnwrapImages)
.use(remarkEmoji)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSlug)
.use(rehypeAutolinkHeadings)
.use(rehypeImageSize, { dir: "public" })
.use(rehypeWrapFigures);
}
// Load a search document from a markdown file.
async function loadSearchDoc<P extends Preamble & { cover?: string }>(
id: number,
page: boolean,
doc_path: string
): Promise<{ doc: IndexDoc; structure: Root } | null> {
const slug = path.basename(doc_path).replace(".md", "");
const { preamble, source } = await loadDocSource<P>(doc_path);
if (typeof preamble.search === "boolean" && !preamble.search) {
return null;
}
const doc = new IndexDoc(id, slug, preamble.title || "No Title");
if (preamble.published) {
doc.published = preamble.published;
}
if (preamble.cover) {
doc.cover = preamble.cover;
}
if (preamble.excerpt) {
doc.excerpt = preamble.excerpt;
}
doc.page = page;
// Process the markdown source to HTML.
const processor = createProcessor();
const root = processor.parse(source);
const hast = processor.runSync(root);
return { doc, structure: hast as Root };
}
/// Build the search index over all pages and blog posts.
///
/// This will return the `IndexBuilder` that can be serialized to a binary file.
async function buildSearchIndex(): Promise<IndexBuilder> {
const index = new IndexBuilder();
let doc_index = 0;
// Iterate through all the pages, extract their source, and add it to the `IndexBuilder`.
const pagesDir = path.join(process.cwd(), "content", "pages");
for (let filename of await fs.readdir(pagesDir)) {
const doc = await loadSearchDoc(
doc_index++,
true,
path.join(pagesDir, filename)
);
if (doc) {
index.addDocument(doc.doc, doc.structure);
}
}
// Iterate through all the blog posts, extract their source, and add it to the `IndexBuilder`.
const postsDir = path.join(process.cwd(), "content", "posts");
for (let filename of await fs.readdir(postsDir)) {
const doc = await loadSearchDoc(
doc_index++,
false,
path.join(postsDir, filename)
);
if (doc) {
index.addDocument(doc.doc, doc.structure);
}
}
// Prepare the final index and return it.
return index;
}
// Write a `IndexBuilder` to the given file under `/public/data/` directory.
async function writeIndex(filename: string, index: IndexBuilder) {
// Create a new binary store.
const store = new Store();
// Encode the prepared index using the store
index.store(store);
console.log(`Generate search index:`);
index.sizes.log();
// Write the contents of the encoder to the destination file.
return fs.writeFile(
path.join(process.cwd(), "public", "data", filename),
Buffer.from(store.finish())
);
}
/// Generate all the search indices for this site.
///
/// Currently we only have the one index, which we store under `/public/data/search.bin` and indexes all pages and blog
/// posts.
///
/// This function will use the `buildSearchIndex` function from the `content` module to build a `PreparedIndex`, which
/// we then store to the `/public/data/search.bin` file.
export async function generateIndices() {
// Create the 'data' directory in the 'public' directory if it doesn't exist. This is where we store the prepared
// index, and is what will be served by CloudFront.
await fs.mkdir(path.join(process.cwd(), "public", "data"), {
recursive: true,
});
// Create the search index and write it to 'search.bin'.
await writeIndex("search.bin", await buildSearchIndex());
}

1
lib/missing.d.ts vendored
View File

@ -1 +0,0 @@
declare module "next-image-export-optimizer";

View File

@ -1,18 +0,0 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "yaml";
/// An entry in the site navigation.
export interface SiteNavigation {
/// The title of the navigation item.
label: string;
/// The URL of the navigation item.
url: string;
}
/// Load the site navigation from the `navigation.yaml` file in the `/content/` directory.
export async function loadNavigation(): Promise<SiteNavigation[]> {
const navPath = path.join(process.cwd(), "content", "navigation.yaml");
const navSrc = await fs.readFile(navPath, "utf-8");
return yaml.parse(navSrc) as SiteNavigation[];
}

Some files were not shown because too many files have changed in this diff Show More