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
149 changed files with 0 additions and 19506 deletions
Showing only changes of commit 294b9dd79a - Show all commits

View File

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

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

@ -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>

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());
}

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[];
}

View File

@ -1,160 +0,0 @@
import { Node } from "unist";
import * as mdast from "mdast";
import * as hast from "hast";
const parseAttributes = (str: string): { [key: string]: string } => {
const attrs: { [key: string]: string } = {};
const regex = /(\S+)=["']?((?:.(?!["']?\s+(?:\S+)=|[>"']))+.)["']?/g;
let match;
while ((match = regex.exec(str)) !== null) {
attrs[match[1]] = match[2];
}
return attrs;
};
// A remark plugin that extracts images that live inside paragraphs, to avoid `<p><img .../></p>`.
export function remarkUnwrapImages() {
function transformChildren(node: mdast.Parent) {
node.children = node.children.map((child) =>
walkNode(child)
) as mdast.Content[];
}
function walkNode(node: Node): Node {
switch (node.type) {
case "root":
transformChildren(node as mdast.Root);
break;
case "paragraph": {
const paragraph = node as mdast.Paragraph;
if (
paragraph.children.length === 1 &&
paragraph.children[0].type === "image"
) {
return walkNode(paragraph.children[0]);
} else {
transformChildren(paragraph);
}
break;
}
}
return node;
}
return (tree: Node) => {
return walkNode(tree);
};
}
// A rehype plugin that wraps code blocks in figures
export function rehypeWrapFigures() {
function transformChildren(node: hast.Parent) {
node.children = node.children.map((child) =>
walkNode(child, node)
) as hast.Content[];
}
function walkNode(node: Node, parent?: hast.Parent): Node {
switch (node.type) {
case "root": {
transformChildren(node as hast.Root);
break;
}
case "element": {
const element = node as hast.Element;
transformChildren(element);
if (
element.tagName === "code" &&
parent &&
parent.type === "element" &&
(parent as hast.Element).tagName === "pre"
) {
element.properties = element.properties || {};
const className = element.properties.className;
if (typeof className === "string") {
element.properties.className = ["block", className];
} else if (className instanceof Array) {
element.properties.className = ["block", ...className];
} else {
element.properties.className = "block";
}
} else if (element.tagName === "pre" && element.children.length > 0) {
const child = element.children[0];
const child_data = (child.data as any) || {};
let caption: string | null = null;
if ("meta" in child_data && typeof child_data["meta"] === "string") {
const meta = parseAttributes(child_data["meta"]);
if ("caption" in meta && typeof meta["caption"] === "string") {
caption = meta["caption"];
}
}
return {
type: "element",
tagName: "figure",
properties: {
className: "code" + (caption ? " caption" : ""),
},
position: element.position,
children: [
element,
...(caption
? [
{
type: "element",
tagName: "figcaption",
children: [{ type: "text", value: caption }],
},
]
: []),
],
} as hast.Node;
} else {
break;
}
}
}
return node;
}
return (tree: Node) => {
return walkNode(tree);
};
}
// A rehype plugin that adds a 'data-path' field to every element indicating it's path (according to seach structure).
export function rehypeAddPaths() {
function transformChildren(path: number[], node: hast.Parent) {
node.children = node.children.map((child, index) =>
walkNode([...path, index], child)
) as hast.Content[];
}
function walkNode(path: number[], node: Node): Node {
switch (node.type) {
case "root":
transformChildren(path, node as hast.Root);
break;
case "element": {
const element = node as hast.Element;
if (typeof element.properties === "undefined") {
element.properties = {};
}
element.properties["data-path"] = path.join(",");
transformChildren(path, element);
}
}
return node;
}
return (tree: Node) => {
return walkNode([], tree);
};
}

View File

@ -1,76 +0,0 @@
import Load from "../encoding/load";
import Store from "../encoding/store";
export default class IndexDoc {
public id: number;
public page: boolean;
public slug: string;
public title: string;
public published: string | null;
public cover: string | null;
public excerpt: string | null;
constructor(id: number, slug: string, title: string) {
this.id = id;
this.page = false;
this.slug = slug;
this.title = title;
this.published = null;
this.cover = null;
this.excerpt = null;
}
public get url(): string {
return this.page ? `/${this.slug}` : `/blog/${this.slug}`;
}
public store(store: Store) {
store.writeUintVlq(
(this.id << 4) |
(this.excerpt ? 0x08 : 0x00) |
(this.cover ? 0x04 : 0x00) |
(this.published ? 0x02 : 0x00) |
(this.page ? 0x01 : 0x00)
);
store.writeUtf8(this.slug);
store.writeUtf8(this.title);
if (this.published) {
store.writeUtf8(this.published);
}
if (this.cover) {
store.writeUtf8(this.cover);
}
if (this.excerpt) {
store.writeUtf8(this.excerpt);
}
}
public static load(load: Load): IndexDoc {
const tag = load.readUintVlq();
const slug = load.readUtf8();
const title = load.readUtf8();
const doc = new IndexDoc(tag >> 4, slug, title);
if ((tag & 0x01) === 0x01) {
doc.page = true;
}
if ((tag & 0x02) == 0x02) {
doc.published = load.readUtf8();
}
if ((tag & 0x04) == 0x04) {
doc.cover = load.readUtf8();
}
if ((tag & 0x08) === 0x08) {
doc.excerpt = load.readUtf8();
}
return doc;
}
}

View File

@ -1,55 +0,0 @@
import Load from "../encoding/load";
import Store from "../encoding/store";
import { DecoderStats } from "../index/stats";
export class IndexDocLocation {
/// The ID of the document in which this location is to be found.
public docId: number;
/// The path through the document structure to this location.
public path: number[];
constructor(docId: number, path: number[]) {
this.docId = docId;
this.path = path;
}
}
/// A cache of all IndexDocLocation records
export class IndexDocLocations {
public locations: Map<number, IndexDocLocation> = new Map();
public addLocation(docId: number, path: number[]): number {
const index = this.locations.size;
this.locations.set(index, new IndexDocLocation(docId, path));
return index;
}
public getLocation(id: number): IndexDocLocation | undefined {
return this.locations.get(id);
}
public store(store: Store) {
store.writeUintVlq(this.locations.size);
for (const [id, location] of this.locations) {
store.writeUintVlq(id);
store.writeUintVlq(location.docId);
store.writeUintVlqSeq(location.path);
}
}
public static load(load: Load, stats: DecoderStats): IndexDocLocations {
const locations = new IndexDocLocations();
let nlocations = load.readUintVlq();
while (nlocations-- > 0) {
const id = load.readUintVlq();
const doc_id = load.readUintVlq();
const path = load.readUintVlqSeq();
locations.locations.set(id, new IndexDocLocation(doc_id, path));
}
stats.sizes.locations += locations.locations.size;
return locations;
}
}

View File

@ -1,72 +0,0 @@
export default class Load {
private buffer: ArrayBuffer;
private offset: number;
private view: DataView;
constructor(buffer: ArrayBuffer) {
this.buffer = buffer;
this.offset = 0;
this.view = new DataView(this.buffer);
}
public get length(): number {
return this.buffer.byteLength;
}
public get remaining(): number {
return this.buffer.byteLength - this.offset;
}
public readUint8(): number {
const value = this.view.getUint8(this.offset);
this.offset += 1;
return value;
}
public readUint16(): number {
const value = this.view.getUint16(this.offset);
this.offset += 2;
return value;
}
public readUint32(): number {
const value = this.view.getUint32(this.offset);
this.offset += 4;
return value;
}
public readUintVlq(): number {
var byte = 0,
value = 0,
shift = 0;
do {
byte = this.view.getUint8(this.offset++);
value |= (byte & 0x7f) << shift;
shift += 7;
} while (byte >= 0x80);
return value;
}
public readUintVlqSeq(): number[] {
const seq: number[] = [];
var value: number;
do {
value = this.readUintVlq();
seq.push(value >> 1);
} while ((value & 0x01) === 0x01);
return seq;
}
public readUtf8(): string {
const len = this.readUintVlq();
const str = new TextDecoder("utf-8").decode(
new DataView(this.buffer, this.offset, len)
);
this.offset += len;
return str;
}
}

View File

@ -1,65 +0,0 @@
export default class Store {
private parts: Uint8Array[] = [];
constructor() {}
public get length(): number {
return this.parts.reduce((acc, part) => acc + part.length, 0);
}
public writeUint8(value: number) {
const arr = new ArrayBuffer(1);
new DataView(arr).setUint8(0, value);
this.parts.push(new Uint8Array(arr));
}
public writeUint16(value: number) {
const arr = new ArrayBuffer(2);
new DataView(arr).setUint16(0, value);
this.parts.push(new Uint8Array(arr));
}
public writeUint32(value: number) {
const arr = new ArrayBuffer(4);
new DataView(arr).setUint32(0, value);
this.parts.push(new Uint8Array(arr));
}
public writeUintVlq(value: number) {
const values: number[] = [];
while (value >= 0x80) {
values.push((value & 0x7f) | 0x80);
value = value >> 7;
}
values.push(value & 0x7f);
this.parts.push(new Uint8Array(values));
}
public writeUtf8(value: string) {
const arr = new TextEncoder().encode(value);
this.writeUintVlq(arr.length);
this.parts.push(arr);
}
public writeUintVlqSeq(seq: number[]) {
for (let i = 0; i < seq.length; i++) {
this.writeUintVlq((seq[i] << 1) | (i === seq.length - 1 ? 0x00 : 0x01));
}
}
public finish(): ArrayBuffer {
const len = this.parts.reduce((acc, part) => acc + part.length, 0);
const arr = new ArrayBuffer(len);
const buf = new Uint8Array(arr);
var offset = 0;
this.parts.forEach((part) => {
buf.set(part, offset);
offset += part.length;
});
return arr;
}
}

View File

@ -1,106 +0,0 @@
import { Element, Node, Root, Text } from "hast";
import IndexDoc from "../document/document";
import { IndexDocLocations } from "../document/location";
import Store from "../encoding/store";
import Tree from "../tree/tree";
import { BuilderSizes } from "./stats";
import { tokenizeCode, tokenizePhrasing } from "./tokens";
export const MAGIC = 0x53524348;
interface WalkStructItem {
path: number[];
tagName: string;
content: string;
}
function* walkStruct(root: Root): Generator<WalkStructItem> {
let path: number[] = [];
let index: number = 0;
let nodes: Node[] = [...root.children];
let tagName: string = "";
let stack: { tagName: string; nodes: Node[] }[] = [];
for (;;) {
while (nodes.length > 0) {
const node = nodes.shift()!;
if (node.type === "text" && (node as Text).value.length > 0) {
yield {
path: [...path, index],
tagName,
content: (node as Text).value,
};
index += 1;
} else if (node.type === "element") {
path.push(index);
stack.push({ tagName, nodes });
const element = node as Element;
nodes = [...element.children];
index = 0;
tagName = element.tagName;
}
}
if (stack.length === 0) {
break;
}
const top = stack.pop()!;
nodes = top.nodes;
tagName = top.tagName;
index = path.pop()! + 1;
}
}
export default class IndexBuilder {
sizes: BuilderSizes = new BuilderSizes();
documents: Map<number, IndexDoc> = new Map();
locations: IndexDocLocations = new IndexDocLocations();
tree: Tree = new Tree();
constructor() {}
public addDocument(doc: IndexDoc, structure: Root) {
if (this.documents.has(doc.id)) {
throw new Error(`Duplicate index document ID ${doc.id}`);
}
this.documents.set(doc.id, doc);
this.sizes.documents += 1;
for (const { path, tagName, content } of walkStruct(structure)) {
const tokens =
tagName === "code" ? tokenizeCode(content) : tokenizePhrasing(content);
if (tokens.length === 0) {
continue;
}
this.sizes.tokens += tokens.length;
this.sizes.locations += 1;
const location_id = this.locations.addLocation(doc.id, path);
for (const token of tokens) {
this.tree.insert(token.text, location_id, {
start: token.start,
length: token.length,
});
}
}
}
public store(store: Store) {
store.writeUint32(MAGIC);
// Store the document information
store.writeUintVlq(this.documents.size);
for (const doc of this.documents.values()) {
doc.store(store);
}
this.locations.store(store);
this.tree.store(store, this.sizes);
this.sizes.size = store.length;
}
}

View File

@ -1,152 +0,0 @@
import IndexDoc from "../document/document";
import { IndexDocLocations } from "../document/location";
import Load from "../encoding/load";
import { mergeRanges, Range } from "../tree/node";
import Tree from "../tree/tree";
import { MAGIC } from "./builder";
import { DecoderStats } from "./stats";
import { tokenizePhrasing } from "./tokens";
export interface SearchPositions {
location_id: number;
ranges: Range[];
}
function mergeSearchPositions(
left: SearchPositions[],
right: SearchPositions[]
): SearchPositions[] {
const combined: SearchPositions[] = [...left];
for (const position of right) {
let found = false;
for (const existing of combined) {
if (existing.location_id === position.location_id) {
mergeRanges(existing.ranges, position.ranges);
found = true;
break;
}
}
if (!found) {
combined.push(position);
}
}
// Sort the combined positions by location ID to ensure they are increasing
return combined.sort((a, b) => a.location_id - b.location_id);
}
export default class PreparedIndex {
public documents: Map<number, IndexDoc> = new Map();
public locations: IndexDocLocations = new IndexDocLocations();
public tree: Tree = new Tree();
public searchTerm(
term: string,
docId?: number
): Map<number, SearchPositions[]> {
const found_locations = this.tree.search(term);
// Iterate through the set of locations, and build a mapping from an IndexDoc ID to an object containing the
// selector and list of positions.
let results: Map<number, SearchPositions[]> = new Map();
for (const [location_id, positions] of found_locations) {
const location = this.locations.getLocation(location_id)!;
// If we're only looking for a certain document, and this location isn't in that document, skip it.
if (typeof docId === "number" && location.docId !== docId) {
continue;
}
const result = results.get(location.docId);
if (result) {
result.push({ location_id, ranges: positions });
} else {
results.set(location.docId, [{ location_id, ranges: positions }]);
}
}
return results;
}
public search(input: string, docId?: number): Map<number, SearchPositions[]> {
const tokens = tokenizePhrasing(input);
if (tokens.length === 0) {
return new Map();
}
const matches = tokens.map((token) => this.searchTerm(token.text, docId));
// Build a set that combines the intersection of all document IDs
let combined_ids: Set<number> | null = null;
for (const match of matches) {
const match_ids = new Set(match.keys());
if (combined_ids === null) {
combined_ids = match_ids;
} else {
for (const doc_id of combined_ids) {
if (!match_ids.has(doc_id)) {
combined_ids.delete(doc_id);
}
}
}
}
// If we didn't find anything, or the intersection of documents was an empty set (no document(s) include all terms),
// then the result of the search is empty.
if (combined_ids === null || combined_ids.size === 0) {
return new Map();
}
// Build the combined map
const combined: Map<number, SearchPositions[]> = new Map();
for (const match of matches) {
for (const [document_id, positions] of match) {
// If this document is not in the combined document IDs, then skip it.
if (!combined_ids.has(document_id)) {
continue;
}
if (!combined.has(document_id)) {
combined.set(document_id, positions);
} else {
let current = combined.get(document_id)!;
combined.set(document_id, mergeSearchPositions(current, positions));
}
}
}
return combined;
}
public static load(load: Load): PreparedIndex {
const start = performance.now();
const stats = new DecoderStats(load.length);
const magic = load.readUint32();
if (magic !== MAGIC) {
console.error(
`Expected file magic 0x${MAGIC.toString(16)}, found 0x${magic.toString(
16
)}`
);
throw new Error(`Invalid prepared index file`);
}
const index = new PreparedIndex();
let doc_count = load.readUintVlq();
while (doc_count-- > 0) {
const doc = IndexDoc.load(load);
index.documents.set(doc.id, doc);
stats.sizes.documents++;
}
index.locations = IndexDocLocations.load(load, stats);
index.tree = Tree.load(load, stats);
stats.timings.load = performance.now() - start;
stats.log();
return index;
}
}

View File

@ -1,57 +0,0 @@
export class BuilderSizes {
documents: number = 0;
locations: number = 0;
tokens: number = 0;
nodes: number = 0;
maxDepth: number = 0;
size: number = 0;
public log() {
const nf = Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
console.log(
`Index of ${nf.format(this.size / 1024.0)} Kib covering ${nf.format(
this.documents
)} document(s) containing ${nf.format(
this.tokens
)} token(s) over ${nf.format(
this.locations
)} location(s) formed a tree of ${nf.format(
this.nodes
)} node(s) (max. depth ${nf.format(this.maxDepth)})`
);
}
}
export class DecoderStats {
public sizes = {
documents: 0,
locations: 0,
nodes: 0,
size: 0,
};
public timings = {
load: 0,
documents: 0,
locations: 0,
tree: 0,
};
constructor(size: number) {
this.sizes.size = size;
}
public log() {
const nf = Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
const { documents, locations, nodes, size } = this.sizes;
console.log(
`Index of ${nf.format(size / 1024.0)} Kib covering ${nf.format(
documents
)} document(s) containing ${nf.format(
locations
)} location(s) and a tree of ${nf.format(
nodes
)} node(s) loaded in ${nf.format(this.timings.load)} ms`
);
}
}

View File

@ -1,164 +0,0 @@
// https://tartarus.org/martin/PorterStemmer/
const STEP2_LIST: { [key: string]: string } = {
ational: "ate",
tional: "tion",
enci: "ence",
anci: "ance",
izer: "ize",
bli: "ble",
alli: "al",
entli: "ent",
eli: "e",
ousli: "ous",
ization: "ize",
ation: "ate",
ator: "ate",
alism: "al",
iveness: "ive",
fulness: "ful",
ousness: "ous",
aliti: "al",
iviti: "ive",
biliti: "ble",
logi: "log",
};
const STEP3_LIST: { [key: string]: string } = {
icate: "ic",
ative: "",
alize: "al",
iciti: "ic",
ical: "ic",
ful: "",
ness: "",
};
// Consonant-vowel sequences.
const CONSONANT = "[^aeiou]";
const VOWEL = "[aeiouy]";
const CONSONANTS = "(" + CONSONANT + "[^aeiouy]*)";
const VOWELS = "(" + VOWEL + "[aeiou]*)";
const GT0 = new RegExp("^" + CONSONANTS + "?" + VOWELS + CONSONANTS);
const EQ1 = new RegExp(
"^" + CONSONANTS + "?" + VOWELS + CONSONANTS + VOWELS + "?$"
);
const GT1 = new RegExp("^" + CONSONANTS + "?(" + VOWELS + CONSONANTS + "){2,}");
const VOWEL_IN_STEM = new RegExp("^" + CONSONANTS + "?" + VOWEL);
const CONSONANT_LIKE = new RegExp("^" + CONSONANTS + VOWEL + "[^aeiouwxy]$");
// Exception expressions.
const SFX_LL = /ll$/;
const SFX_E = /^(.+?)e$/;
const SFX_Y = /^(.+?)y$/;
const SFX_ION = /^(.+?(s|t))(ion)$/;
const SFX_ED_OR_ING = /^(.+?)(ed|ing)$/;
const SFX_AT_OR_BL_OR_IZ = /(at|bl|iz)$/;
const SFX_EED = /^(.+?)eed$/;
const SFX_S = /^.+?[^s]s$/;
const SFX_SSES_OR_IES = /^.+?(ss|i)es$/;
const SFX_MULTI_CONSONANT_LIKE = /([^aeiouylsz])\1$/;
const STEP2 =
/^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/;
const STEP3 = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/;
const STEP4 =
/^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/;
export default function stemmer(value: string): string {
// Exit early.
if (value.length < 3) {
return value;
}
let firstCharacterWasLowerCaseY = false;
// Detect initial `y`, make sure it never matches.
if (
value.codePointAt(0) === 121 // Lowercase Y
) {
firstCharacterWasLowerCaseY = true;
value = "Y" + value.slice(1);
}
// Step 1a.
if (SFX_SSES_OR_IES.test(value)) {
// Remove last two characters.
value = value.slice(0, -2);
} else if (SFX_S.test(value)) {
// Remove last character.
value = value.slice(0, -1);
}
let match: RegExpMatchArray | null;
// Step 1b.
if ((match = SFX_EED.exec(value))) {
if (GT0.test(match[1])) {
// Remove last character.
value = value.slice(0, -1);
}
} else if (
(match = SFX_ED_OR_ING.exec(value)) &&
VOWEL_IN_STEM.test(match[1])
) {
value = match[1];
if (SFX_AT_OR_BL_OR_IZ.test(value)) {
// Append `e`.
value += "e";
} else if (SFX_MULTI_CONSONANT_LIKE.test(value)) {
// Remove last character.
value = value.slice(0, -1);
} else if (CONSONANT_LIKE.test(value)) {
// Append `e`.
value += "e";
}
}
// Step 1c.
if ((match = SFX_Y.exec(value)) && VOWEL_IN_STEM.test(match[1])) {
// Remove suffixing `y` and append `i`.
value = match[1] + "i";
}
// Step 2.
if ((match = STEP2.exec(value)) && GT0.test(match[1])) {
value = match[1] + STEP2_LIST[match[2]];
}
// Step 3.
if ((match = STEP3.exec(value)) && GT0.test(match[1])) {
value = match[1] + STEP3_LIST[match[2]];
}
// Step 4.
if ((match = STEP4.exec(value))) {
if (GT1.test(match[1])) {
value = match[1];
}
} else if ((match = SFX_ION.exec(value)) && GT1.test(match[1])) {
value = match[1];
}
// Step 5.
if (
(match = SFX_E.exec(value)) &&
(GT1.test(match[1]) ||
(EQ1.test(match[1]) && !CONSONANT_LIKE.test(match[1])))
) {
value = match[1];
}
if (SFX_LL.test(value) && GT1.test(value)) {
value = value.slice(0, -1);
}
// Turn initial `Y` back to `y`.
if (firstCharacterWasLowerCaseY) {
value = "y" + value.slice(1);
}
return value;
}

View File

@ -1,168 +0,0 @@
const STOP_WORDS = [
"that",
"been",
"him",
"us",
"would",
"own",
"or",
"yourselves",
"new",
"no",
"such",
"below",
"did",
"if",
"myself",
"against",
"do",
"because",
"am",
"back",
"his",
"to",
"what",
"people",
"make",
"who",
"but",
"on",
"there",
"between",
"way",
"other",
"than",
"which",
"while",
"see",
"all",
"I",
"was",
"them",
"of",
"just",
"good",
"she",
"whom",
"day",
"only",
"two",
"first",
"know",
"ourselves",
"come",
"he",
"from",
"why",
"few",
"for",
"their",
"one",
"the",
"this",
"any",
"down",
"more",
"ours",
"we",
"think",
"will",
"about",
"above",
"were",
"be",
"our",
"themselves",
"having",
"they",
"time",
"say",
"under",
"once",
"doing",
"further",
"yours",
"look",
"with",
"want",
"in",
"how",
"like",
"has",
"had",
"give",
"by",
"it",
"during",
"nor",
"t",
"a",
"could",
"very",
"some",
"well",
"have",
"your",
"is",
"so",
"you",
"i",
"after",
"yourself",
"even",
"should",
"when",
"himself",
"at",
"its",
"and",
"too",
"same",
"until",
"hers",
"as",
"don",
"most",
"also",
"herself",
"take",
"again",
"before",
"these",
"through",
"both",
"theirs",
"use",
"her",
"those",
"where",
"year",
"being",
"does",
"off",
"are",
"s",
"over",
"here",
"me",
"go",
"into",
"each",
"work",
"up",
"an",
"itself",
"my",
"get",
"out",
"can",
"then",
"not",
"now",
];
const STOP_SET: Set<string> = new Set(STOP_WORDS);
export default function isStopWord(word: string): boolean {
return STOP_SET.has(word);
}

View File

@ -1,74 +0,0 @@
import stemmer from "./stem";
import isStopWord from "./stop";
const SEPARATOR_RE = /[\W]+/;
const IDENTIFIER_RE = /[\w_-]+/g;
export class Token {
public start: number;
public length: number;
public text: string;
constructor(start: number, length: number, text: string) {
this.start = start;
this.length = length;
this.text = text;
}
}
export function tokenizePhrasing(input: string): Token[] {
input = input.toLowerCase();
const tokens: Token[] = [];
for (let end = 0, start = 0; end <= input.length; ++end) {
const char = input.charAt(end);
const len = end - start;
if (char.match(SEPARATOR_RE) || end == input.length) {
if (len > 0) {
let text = input
.slice(start, end)
.replace(/^\W+/, "")
.replace(/\W+$/, "");
if (text.length > 0 && !isStopWord(text)) {
let token = new Token(start, len, stemmer(text));
tokens.push(token);
}
}
start = end + 1;
}
}
return tokens;
}
export function tokenizeCode(input: string): Token[] {
const tokens: Token[] = [];
for (const match of input.matchAll(IDENTIFIER_RE)) {
const parts = match[0].split(/[_]+/);
let offset = 0;
for (let i = 0; i < parts.length; ++i) {
for (const subpart of parts[i].split(/(?=[A-Z])/)) {
if (subpart.length > 2) {
tokens.push(
new Token(
(match.index || 0) + offset,
subpart.length,
subpart.toLowerCase()
)
);
}
offset += subpart.length;
}
offset += 1;
}
}
return tokens;
}

View File

@ -1,30 +0,0 @@
import { promises as fs } from "fs";
import TreeNode from "./node";
import Tree from "./tree";
export async function writeTreeDigraph(path: string, tree: Tree) {
const lines: string[] = ["digraph {"];
let node_index = 0;
function walk(node: TreeNode): number {
let node_id = node_index++;
lines.push(` node${node_id} [label=${JSON.stringify(node.fragment)}];`);
for (const [ch, child] of node.children) {
const child_id = walk(child);
lines.push(
` node${node_id} -> node${child_id} [label=${JSON.stringify(
String.fromCharCode(ch)
)}];`
);
}
return node_id;
}
for (const node of tree.root.children.values()) {
walk(node);
}
lines.push("}");
await fs.writeFile(path, lines.join("\n"), "utf-8");
}

View File

@ -1,117 +0,0 @@
import Load from "../encoding/load";
import Store from "../encoding/store";
export interface Range {
start: number;
length: number;
}
export function mergeRanges(as: Range[], bs: Range[]) {
for (const b of bs) {
const b_start = b.start;
const b_end = b.start + b.length;
let inserted = false;
for (let i = 0; i < as.length; ++i) {
const a_start = as[i].start;
const a_end = a_start + as[i].length;
if (a_end < b_start) {
continue;
}
if (a_start > b_end) {
inserted = true;
as.splice(i, 0, b);
break;
}
as[i].start = Math.min(a_start, b_start);
const end = Math.max(a_end, b_end);
as[i].length = end - as[i].start;
inserted = true;
break;
}
if (!inserted) {
as.push(b);
}
}
}
/// A node in the search tree
///
/// Each node in the search tree contains a set of zero or more children, where each child is indexed by character. Each
/// node also contains a mapping from a location ID to an array of one or more ranges.
export default class TreeNode {
public fragment: string;
public children: Map<number, TreeNode> = new Map();
public ranges: Map<number, Range[]> = new Map();
constructor(fragment: string = "") {
this.fragment = fragment;
}
public addRange(location_id: number, position: Range) {
let ranges = this.ranges.get(location_id);
if (ranges) {
ranges.push(position);
} else {
this.ranges.set(location_id, [position]);
}
}
public store(store: Store, key: number) {
const tag =
(key << 2) |
(this.ranges.size > 0 ? 0x02 : 0x00) |
(this.children.size > 0 ? 0x01 : 0x00);
store.writeUintVlq(tag);
store.writeUtf8(this.fragment);
if (this.ranges.size > 0) {
store.writeUintVlq(this.ranges.size);
for (const [location_id, ranges] of this.ranges) {
store.writeUintVlq(location_id);
store.writeUintVlq(ranges.length);
for (const range of ranges) {
store.writeUintVlq(range.start);
store.writeUintVlq(range.length);
}
}
}
}
public static load(load: Load): {
key: number;
hasChildren: boolean;
node: TreeNode;
} {
const tag = load.readUintVlq();
const fragment = load.readUtf8();
const node = new TreeNode(fragment);
if ((tag & 0x02) === 0x02) {
let nlocations = load.readUintVlq();
while (nlocations-- > 0) {
const location_id = load.readUintVlq();
const positions: Range[] = [];
let npositions = load.readUintVlq();
while (npositions-- > 0) {
const start = load.readUintVlq();
const length = load.readUintVlq();
positions.push({ start, length });
}
node.ranges.set(location_id, positions);
}
}
return {
key: tag >> 2,
hasChildren: (tag & 1) !== 0,
node,
};
}
}

View File

@ -1,209 +0,0 @@
import Load from "../encoding/load";
import Store from "../encoding/store";
import { BuilderSizes, DecoderStats } from "../index/stats";
import TreeNode, { Range } from "./node";
function getCommonPrefix(left: string, right: string): string {
let prefix = "";
for (let i = 0; i < Math.min(left.length, right.length); ++i) {
if (left[i] === right[i]) {
prefix += left[i];
} else {
break;
}
}
return prefix;
}
export default class Tree {
public root: TreeNode;
constructor(root?: TreeNode) {
this.root = root || new TreeNode();
}
public insert(text: string, location_id: number, range: Range) {
let node = this.root;
for (let i = 0; i < text.length; ) {
const ch = text.charCodeAt(i);
const remainder = text.substring(i);
let child = node.children.get(ch);
if (child) {
// If the child's fragment and the remainder of 'text' are the same, then this is our node we want to change.
if (child.fragment === remainder) {
child.addRange(location_id, range);
return;
}
// Get the common prefix of the child's fragment and the remainder of 'text'.
const prefix = getCommonPrefix(child.fragment, remainder);
if (prefix.length < child.fragment.length) {
let mid: TreeNode | null = null;
if (prefix.length === text.length - i) {
mid = new TreeNode(remainder);
mid.addRange(location_id, range);
} else if (prefix.length < text.length - i) {
mid = new TreeNode(prefix);
const tail = new TreeNode(remainder.substring(prefix.length));
tail.addRange(location_id, range);
mid.children.set(tail.fragment.charCodeAt(0), tail);
}
if (mid !== null) {
// Set the 'child' node to be a child of the new 'mid' node
mid.children.set(child.fragment.charCodeAt(prefix.length), child);
// Update the fragment of 'child' to be what's left of the fragment.
child.fragment = child.fragment.substring(prefix.length);
// Replace 'child' with 'mid' in 'node'
node.children.set(ch, mid);
return;
}
}
i += child.fragment.length;
node = child;
} else {
child = new TreeNode(text.substring(i));
child.addRange(location_id, range);
node.children.set(ch, child);
return;
}
}
}
public search(prefix: string): Map<number, Range[]> {
let node = this.root;
for (let i = 0; i < prefix.length; ) {
const ch = prefix.charCodeAt(i);
let child = node.children.get(ch);
if (child) {
// Get the common prefix between the remainder of the prefix and the child's fragment.
const common_prefix = getCommonPrefix(
child.fragment,
prefix.substring(i)
);
// If the common prefix doesn't match the fragment or what's left of the prefix, this prefix cannot be found in
// the tree.
if (
common_prefix.length !== child.fragment.length &&
common_prefix.length !== prefix.length - i
) {
return new Map();
}
i += child.fragment.length;
node = child;
} else {
// No child exists with this character, so the prefix cannot be found in the tree.
return new Map();
}
}
const found_ranges: Map<number, Range[]> = new Map();
// Collect up all the ranges of the tree nodes from 'node'.
function collect(node: TreeNode) {
if (node.ranges.size > 0) {
for (const [location_id, ranges] of node.ranges) {
let result_ranges = found_ranges.get(location_id);
found_ranges.set(
location_id,
result_ranges ? [...result_ranges, ...ranges] : ranges
);
}
}
for (const child of node.children.values()) {
collect(child);
}
}
collect(node);
return found_ranges;
}
public store(store: Store, sizes: BuilderSizes) {
// Prepare a variable to store our stack depth and a function that will write the stack depth to the store. This
// instruction is used to tell our decoder how far back we want to pop the construction stack to get back to our
// parent.
let stackDepth = 0;
function retrace() {
if (stackDepth > 0) {
store.writeUintVlq(stackDepth);
sizes.maxDepth = Math.max(sizes.maxDepth, stackDepth);
stackDepth = 0;
}
}
function encodeNode(key: number, node: TreeNode) {
// Retrate our steps back to the parent node
retrace();
// Store the tree node, and if the node has children, recursively store those aswell.
node.store(store, key);
for (const [child_key, child] of node.children) {
encodeNode(child_key, child);
}
// We have finished this node, so increment our stack depth and node count.
stackDepth++;
sizes.nodes++;
}
encodeNode(0, this.root);
retrace();
}
public static load(load: Load, stats: DecoderStats): Tree {
let start = performance.now();
let stack: TreeNode[] = [];
let root: TreeNode | null = null;
for (;;) {
// Decode the tree node from the buffer.
const { key, hasChildren, node } = TreeNode.load(load);
stats.sizes.nodes++;
// If there is a node on the top of the stack, then add this node as a child.
if (stack.length > 0) {
stack[stack.length - 1].children.set(key, node);
}
// Push this node onto the stack, and if there's no root node yet, then this is the root node.
stack.push(node);
if (!root) {
root = node;
}
// If we don't have any children, then read the number of stack elements to pop to get back to the correct parent
// node. This value was previously written by 'retrace'. If we end up with an empty stack then we've reached the
// end of the tree.
if (!hasChildren) {
let depth = load.readUintVlq();
while (depth-- > 0) {
stack.pop();
}
if (stack.length === 0) {
break;
}
}
}
stats.timings.tree = performance.now() - start;
return new Tree(root);
}
}

View File

@ -1,71 +0,0 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "yaml";
/// The ID (slug) of a tag.
export type TagId = string;
/// Represents a tag.
export interface Tag {
/// The ID (slug) of the tag.
slug: TagId;
/// The title of the tag.
name: string;
/// Whether the tag is visible or not (only public tags are shown).
visibility?: "public" | "private";
/// A quick description of the tag.
description?: string;
}
/// A mapping from a `TagId` to a `Tag`.
export type Tags = Map<TagId, Tag>;
/// Load the tags from the `/contents/tags.yaml` file.
///
/// Each entry in the `tags.yaml` file describes a tag for the site. Each tag can have the following fields:
///
/// 1. A `slug` field, which overrides the name used in the YAML dictionary,
/// 2. A `name` field, which is used as the title of the tag. If no `name` is given, the slug is used instead.
/// 3. The `visibility` of the tag. If missing, the visibility is assumed to be `"public"`.
/// 4. Any `description` for the tag.
export async function loadTags(): Promise<Tags> {
const tagsPath = path.join(process.cwd(), "content", "tags.yaml");
const tagsSrc = await fs.readFile(tagsPath, "utf-8");
const tags = yaml.parse(tagsSrc) as { [key: string]: Tag };
const res: Tags = new Map();
Object.keys(tags).forEach((tag_id) => {
const tag = tags[tag_id];
if (!tag.slug) {
tag.slug = tag_id;
}
if (!tag.name) {
tag.name = tag_id;
}
if (!tag.visibility) {
tag.visibility = "public";
}
res.set(tag_id, tag);
});
return res;
}
/// Given a slug, retrieve the `Tag` with that slug.
///
/// Note that this function will throw an error if the tag does not exist. Additionally, this function will load the
/// entire set of tags from the `/contents/tags.yaml` file by way of the `loadTags` function.
export async function getTagWithSlug(slug: string): Promise<Tag> {
const tags = await loadTags();
for (let tag of tags.values()) {
if (tag.slug === slug) {
return tag;
}
}
return Promise.reject(`Unable to find tag with slug '${slug}'`);
}

View File

@ -1,144 +0,0 @@
import { Currency, ExchangeRates } from "./forex";
const ACCOUNT_VERSION: number = 1;
export interface AccountInfo {
places: number;
currency: Currency;
exchangeRates: ExchangeRates;
amount: number;
marginRisk: number;
positionRisk: number;
}
export interface SetPlacesAccountAction {
action: "setPlaces";
places: number;
}
export interface SetCurrencyAccountAction {
action: "setCurrency";
currency: Currency;
}
export interface SetExchangeRatesAccountAction {
action: "setExchangeRates";
exchangeRates: ExchangeRates;
}
export interface SetAmountAccountAction {
action: "setAmount";
amount: number;
}
export interface SetMarginRiskAction {
action: "setMarginRisk";
risk: number;
}
export interface SetPositionRiskAction {
action: "setPositionRisk";
risk: number;
}
export type AccountAction =
| SetPlacesAccountAction
| SetCurrencyAccountAction
| SetExchangeRatesAccountAction
| SetAmountAccountAction
| SetMarginRiskAction
| SetPositionRiskAction;
export function accountReducer(
state: AccountInfo,
action: AccountAction
): AccountInfo {
let new_value = null;
switch (action.action) {
case "setPlaces":
new_value = { ...state, places: action.places };
break;
case "setCurrency":
new_value = { ...state, currency: action.currency };
break;
case "setExchangeRates":
new_value = { ...state, exchangeRates: action.exchangeRates };
break;
case "setAmount":
new_value = { ...state, amount: action.amount };
break;
case "setMarginRisk":
new_value = { ...state, marginRisk: action.risk };
break;
case "setPositionRisk":
new_value = { ...state, positionRisk: action.risk };
break;
default:
console.error("Unrecognized account action", action);
return state;
}
storeAccount(new_value);
return new_value;
}
export function storeAccount(account: AccountInfo) {
window.localStorage.setItem(
"blakerain.tools.accountinfo",
JSON.stringify({
...account,
exchangeRates: {
base: account.currency,
rates: Array.from(account.exchangeRates.rates).reduce(
(obj, [key, value]) => {
obj[key] = value;
return obj;
},
{} as { [currency: string]: number }
),
},
__version: ACCOUNT_VERSION,
})
);
}
export function deleteStoredAccount() {
window.localStorage.removeItem("blakerain.tools.accountinfo");
}
export function loadAccount(): AccountInfo | null {
if (typeof window === "undefined") {
return null;
}
const value = window.localStorage.getItem("blakerain.tools.accountinfo");
if (typeof value === "string") {
const account = JSON.parse(value) as AccountInfo & {
exchangeRates: { rates: { [name: string]: number } };
__version: number;
};
if (
typeof account.__version !== "number" ||
account.__version !== ACCOUNT_VERSION
) {
deleteStoredAccount();
return null;
}
const rates = new Map<Currency, number>();
for (let symbol of Object.keys(account.exchangeRates.rates)) {
rates.set(symbol as Currency, account.exchangeRates.rates[symbol]);
}
return {
...account,
exchangeRates: {
base: account.currency,
rates,
},
};
}
return null;
}

View File

@ -1,53 +0,0 @@
export type Currency = "AUD" | "CAD" | "EUR" | "GBP" | "JPY" | "USD";
export const CURRENCIES: Currency[] = [
"AUD",
"CAD",
"EUR",
"GBP",
"JPY",
"USD",
];
export const CURRENCY_SYMBOLS: Map<Currency, string> = new Map([
["AUD", "$"],
["CAD", "$"],
["EUR", "€"],
["GBP", "£"],
["JPY", "¥"],
["USD", "$"],
]);
export interface ExchangeRates {
/// The base currency
base: Currency;
/// The rates converting from this currency
rates: Map<Currency, number>;
}
interface ExchangeRateResult {
motd?: { msg?: string; url?: string };
success: boolean;
base: string;
date: string;
rates: { [key: string]: number };
}
export async function getExchangeRates(
base: Currency,
target?: Currency
): Promise<ExchangeRates> {
const symbols = target || CURRENCIES.join(",");
const res: ExchangeRateResult = await (
await fetch(
`https://api.exchangerate.host/latest?base=${base}&symbols=${symbols}`
)
).json();
const rates = new Map<Currency, number>();
for (let symbol of Object.keys(res.rates)) {
rates.set(symbol as Currency, res.rates[symbol]);
}
return { base, rates };
}

View File

@ -1,306 +0,0 @@
import { AccountInfo } from "./account";
import { Currency } from "./forex";
export type Direction = "buy" | "sell";
export interface PositionInfo {
posCurrency: Currency;
quoteCurrency: Currency;
conversion: number;
openPrice: number;
quantity: number | null;
direction: Direction;
margin: number;
takeProfit: number | null;
stopLoss: number | null;
}
export interface SetPosCurrencyPositionAction {
action: "setPosCurrency";
currency: Currency;
}
export interface SetQuoteCurrencyPositionAction {
action: "setQuoteCurrency";
currency: Currency;
}
export interface SetConversionPositionAction {
action: "setConversion";
conversion: number;
}
export interface SetOpenPricePositionAction {
action: "setOpenPrice";
openPrice: number;
}
export interface SetQuantityPositionAction {
action: "setQuantity";
quantity: number | null;
}
export interface SetDirectionPositionAction {
action: "setDirection";
direction: Direction;
}
export interface SetMarginPositionAction {
action: "setMargin";
margin: number;
}
export interface SetTakeProfitPositionAction {
action: "setTakeProfit";
takeProfit: number | null;
}
export interface SetStopLossPositionAction {
action: "setStopLoss";
stopLoss: number | null;
}
export type PositionAction =
| SetPosCurrencyPositionAction
| SetQuoteCurrencyPositionAction
| SetConversionPositionAction
| SetOpenPricePositionAction
| SetQuantityPositionAction
| SetDirectionPositionAction
| SetMarginPositionAction
| SetTakeProfitPositionAction
| SetStopLossPositionAction;
export function positionReducer(
state: PositionInfo,
action: PositionAction
): PositionInfo {
switch (action.action) {
case "setPosCurrency":
return { ...state, posCurrency: action.currency };
case "setQuoteCurrency":
return { ...state, quoteCurrency: action.currency };
case "setConversion":
return { ...state, conversion: action.conversion };
case "setOpenPrice":
return { ...state, openPrice: action.openPrice };
case "setQuantity":
return { ...state, quantity: action.quantity };
case "setDirection": {
let takeProfit = state.takeProfit;
let stopLoss = state.stopLoss;
if (state.direction !== action.direction) {
if (typeof takeProfit === "number") {
let tp_distance =
state.direction === "buy"
? takeProfit - state.openPrice
: state.openPrice - takeProfit;
takeProfit =
action.direction === "buy"
? state.openPrice + tp_distance
: state.openPrice - tp_distance;
}
if (typeof stopLoss === "number") {
let sl_distance =
state.direction === "buy"
? state.openPrice - stopLoss
: stopLoss - state.openPrice;
stopLoss =
action.direction === "buy"
? state.openPrice - sl_distance
: state.openPrice + sl_distance;
}
}
return { ...state, direction: action.direction, takeProfit, stopLoss };
}
case "setMargin":
return { ...state, margin: action.margin };
case "setTakeProfit":
return { ...state, takeProfit: action.takeProfit };
case "setStopLoss":
return { ...state, stopLoss: action.stopLoss };
default:
console.error("Unrecognized position action", action);
return state;
}
}
interface PositionSize {
/// Funds available under margin risk (in account currency)
available: number;
/// Funds available under margin risk (in position currency)
availablePos: number;
/// Funds available under margin risk (in quote currency)
availableQuote: number;
/// Margin available under margin risk (in account currency)
margin: number;
/// Margin available (in position currency)
marginPos: number;
/// Margin available (in quote currency)
marginQuote: number;
/// Quantity affordable at position price (as units)
quantity: number;
/// Optional actual position size margin risk
actual: null | {
/// The actual quote cost (in quote currency)
costQuote: number;
/// The actual position const (in position currency)
costPos: number;
/// The actual position cost (in account currency)
cost: number;
/// The account margin required (as a %)
margin: number;
};
}
export function computePositionSize(
account: AccountInfo,
position: PositionInfo
): PositionSize {
const p_rate = account.exchangeRates.rates.get(position.posCurrency) || 1;
const q_rate = position.conversion;
const available = account.amount * account.marginRisk;
const availablePos = available * p_rate;
const availableQuote = availablePos * q_rate;
const margin = available / (position.margin || 1);
const marginPos = margin * p_rate;
const marginQuote = marginPos * q_rate;
const quantity = marginQuote / position.openPrice;
const computeActual = (actualQuantity: number) => {
const costQuote = actualQuantity * position.openPrice * position.margin;
const costPos = costQuote / q_rate;
const cost = costPos / p_rate;
const margin = cost / account.amount;
return { costQuote, costPos, cost, margin };
};
const actual =
typeof position.quantity === "number"
? computeActual(position.quantity)
: null;
return {
available,
availablePos,
availableQuote,
margin,
marginPos,
marginQuote,
quantity,
actual,
};
}
interface StopLoss {
/// Funds available under position risk (in account currency)
available: number;
/// Funds available under position risk (in position currency)
availablePos: number;
/// Funds available under position risk (in quote currency)
availableQuote: number;
/// Specified position size
quantity: number;
/// Required stop-loss distance
distance: number;
/// Optional actual stop-loss assessment
actual: null | {
/// The actual stop-loss distance (in position currency)
distance: number;
/// The possible loss
loss: number;
/// The actual position risk (as a %)
risk: number;
};
}
export function computeStopLoss(
account: AccountInfo,
position: PositionInfo,
quantity: number
): StopLoss {
const p_rate = account.exchangeRates.rates.get(position.posCurrency) || 1;
const q_rate = position.conversion;
const available = account.amount * account.positionRisk;
const availablePos = available * p_rate;
const availableQuote = availablePos * q_rate;
const distance = quantity === 0 ? 0 : availableQuote / quantity;
const computeActual = (stopLoss: number) => {
const actualDistance =
position.direction === "buy"
? position.openPrice - stopLoss
: stopLoss - position.openPrice;
const loss = (actualDistance * quantity) / (p_rate * q_rate);
const risk = loss / account.amount;
return {
distance: actualDistance,
loss,
risk,
};
};
const actual =
typeof position.stopLoss === "number"
? computeActual(position.stopLoss)
: null;
return {
available,
availablePos,
availableQuote,
quantity,
distance,
actual,
};
}
interface StopLossQuantity {
/// Funds available under position risk (in account currency)
available: number;
/// Funds available under position risk (in position currency)
availablePos: number;
/// Funds available under positin risk (in quote currency)
availableQuote: number;
/// Computed stop loss distance (in position currency)
stopLossDistance: number;
/// Amount that can be bought at the given stop loss (as units)
quantity: number;
/// Required margin for that amount (in account currency)
margin: number;
}
export function computedStopLossQuantity(
account: AccountInfo,
position: PositionInfo
): StopLossQuantity {
const stopLossDistance =
typeof position.stopLoss === "number"
? position.direction === "buy"
? position.openPrice - position.stopLoss
: position.stopLoss - position.openPrice
: 0;
const p_rate = account.exchangeRates.rates.get(position.posCurrency) || 1;
const q_rate = position.conversion;
const available = account.amount * account.positionRisk;
const availablePos = available * p_rate;
const availableQuote = availablePos * q_rate;
const quantity = availableQuote / stopLossDistance;
const margin =
(quantity * position.openPrice * position.margin) / (p_rate * q_rate);
return {
available,
availablePos,
availableQuote,
stopLossDistance,
quantity,
margin,
};
}

View File

@ -1,2 +0,0 @@
export type Direction = "up" | "down" | "left" | "right";

View File

@ -1,36 +0,0 @@
/// Pad a number with zeros to a specific length.
export function zeroPad(n: number, count: number = 2): string {
const s = n.toString();
if (s.length < count) {
return new Array(count - s.length).fill("0") + s;
} else {
return s;
}
}
/// Format a number
///
/// This function will format the given number to a certain number of decimal places. It will also comma-separate any
/// lengthy numbers. Numbers may also have prefixes and suffixes added to them.
export function formatNumber(
value: number,
places: number,
prefix?: string,
suffix?: string
): string {
const aval = Math.abs(value);
const neg = value < 0;
var parts = aval.toFixed(places).split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return (neg ? "-" : "") + (prefix || "") + parts.join(".") + (suffix || "");
}
/// Get the ISO week for the given `Date`.
export function getISOWeek(date: Date): number {
var d = new Date(date);
var dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
var yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
var diff = d.getTime() - yearStart.getTime();
return Math.ceil((diff / 86400000 + 1) / 7);
}

5
old/next-env.d.ts vendored
View File

@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -1,12 +0,0 @@
export default {
openGraph: {
type: "website",
locale: "en_GB",
url: "https://www.blakerain.com/",
site_name: "Blake Rain",
},
twitter: {
handle: "@HalfWayMan",
cardType: "summary",
},
};

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