4 |
5 | export type PlainFunction = (...args: P[]) => R
6 |
7 | export type Mutable = { -readonly [P in keyof T]: T[P] }
8 |
9 | export type SerializedTextMetrics = Mutable
10 |
11 | export type Font = FontProperties | Pick | string
12 |
13 | export interface FontProperties {
14 | /**
15 | * A list of one or more font family names.
16 | */
17 | fontFamily: string
18 |
19 | /**
20 | * Set the size of the font.
21 | */
22 | fontSize: number
23 |
24 | /**
25 | * Select a normal, condensed, or expanded face from the font.
26 | */
27 | fontStretch?: string
28 |
29 | /**
30 | * Select a normal, italic, or oblique face from the font.
31 | */
32 | fontStyle?: string
33 |
34 | /**
35 | * Select variants from the font.
36 | */
37 | fontVariant?: string
38 |
39 | /**
40 | * Set the weight of the font.
41 | */
42 | fontWeight?: number | string
43 |
44 | /**
45 | * Define how tall a line of text should be.
46 | */
47 | lineHeight?: number
48 | }
49 |
--------------------------------------------------------------------------------
/packages/typometer/tests/utils/get-font.test.ts:
--------------------------------------------------------------------------------
1 | import * as assert from "uvu/assert"
2 | import type { FontProperties } from "../../src"
3 | import { getFontProperties } from "../../src/utils/get-font-properties"
4 | import {
5 | family,
6 | line,
7 | size,
8 | stretch,
9 | style,
10 | variant,
11 | weight
12 | } from "../constants"
13 | import { getComputedFont } from "../helpers"
14 |
15 | describe("getFont", () => {
16 | it("should return the same font appearance from properties, an existing font string or a CSSStyleDeclaration", () => {
17 | const properties: FontProperties = {
18 | fontFamily: family,
19 | fontSize: size,
20 | fontStretch: stretch,
21 | fontStyle: style,
22 | fontVariant: variant,
23 | fontWeight: weight,
24 | lineHeight: line
25 | }
26 |
27 | const string = getFontProperties(properties)
28 | const empty = undefined
29 |
30 | const paragraph = document.createElement("p")
31 | document.body.append(paragraph)
32 | paragraph.style.font = string as string
33 | const styles = window.getComputedStyle(paragraph)
34 |
35 | assert.equal(getComputedFont(properties), getComputedFont(string))
36 | assert.equal(getComputedFont(string), getComputedFont(styles))
37 | assert.equal(getComputedFont(styles), getComputedFont(properties))
38 | assert.equal(getComputedFont(empty), getComputedFont())
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/packages/typometer/src/index.ts:
--------------------------------------------------------------------------------
1 | import { isArray } from "./guards"
2 | import { measureText } from "./measure-text"
3 | import type { Font, SerializedTextMetrics } from "./types"
4 |
5 | /**
6 | * Measure text asynchronously.
7 | *
8 | * @param text - The text to measure, as a single string or an array of different strings.
9 | * @param [font] - The font properties to set.
10 | * @returns A promise fulfilling into serialized `TextMetrics`.
11 | *
12 | * @example
13 | *
14 | * ```js
15 | * const metrics = await typometer("With impressions chosen from another time.")
16 | *
17 | * // metrics: TextMetrics
18 | * ```
19 | */
20 | export async function typometer(
21 | text: string,
22 | font?: Font
23 | ): Promise
24 | export async function typometer(
25 | text: string[],
26 | font?: Font
27 | ): Promise
28 | export async function typometer(
29 | text: string[] | string,
30 | font?: Font
31 | ): Promise {
32 | let metrics: SerializedTextMetrics | SerializedTextMetrics[]
33 |
34 | if (isArray(text)) {
35 | metrics = []
36 |
37 | for (const content of text) {
38 | metrics.push(await measureText(content, font))
39 | }
40 | } else {
41 | metrics = await measureText(text, font)
42 | }
43 |
44 | return metrics
45 | }
46 |
47 | export { typometer as measure }
48 |
49 | export type { Font, FontProperties, SerializedTextMetrics } from "./types"
50 |
--------------------------------------------------------------------------------
/packages/typometer/src/guards.ts:
--------------------------------------------------------------------------------
1 | import type { PlainFunction, Unpack } from "./types"
2 |
3 | /**
4 | * Whether the value is an array.
5 | *
6 | * @param value - The value to check.
7 | */
8 | export const isArray = Array.isArray
9 |
10 | /**
11 | * Whether the value is undefined.
12 | *
13 | * @param value - The value to check.
14 | */
15 | export function isUndefined(value: T | undefined): value is undefined {
16 | return value === undefined
17 | }
18 |
19 | /**
20 | * Whether the value is a number.
21 | *
22 | * @param value - The value to check.
23 | */
24 | export function isNumber(value: number | unknown): value is number {
25 | return typeof value === "number"
26 | }
27 |
28 | /**
29 | * Whether the value is a string.
30 | *
31 | * @param value - The value to check.
32 | */
33 | export function isString(value: string | unknown): value is string {
34 | return typeof value === "string"
35 | }
36 |
37 | /**
38 | * Whether the value is a function.
39 | *
40 | * @param value - The value to check.
41 | */
42 | export function isFunction(
43 | value: T | unknown
44 | ): value is PlainFunction>, ReturnType> {
45 | return value instanceof Function
46 | }
47 |
48 | /**
49 | * Whether the value is a `CSSStyleDeclaration`.
50 | *
51 | * @param value - The value to check.
52 | */
53 | export function isCSSStyleDeclaration(
54 | value: CSSStyleDeclaration | unknown
55 | ): value is CSSStyleDeclaration {
56 | return value instanceof CSSStyleDeclaration
57 | }
58 |
--------------------------------------------------------------------------------
/packages/typometer/src/utils/get-font-properties.ts:
--------------------------------------------------------------------------------
1 | import type { FontProperties } from "../types"
2 |
3 | const DEFAULT_FONT_SIZE_UNIT = "px"
4 |
5 | /**
6 | * Merge a font size and an optional line height into a shorthand declaration.
7 | *
8 | * @param fontSize - The font size to merge.
9 | * @param lineHeight - The line height to merge.
10 | */
11 | function getFontSizeWithLineHeight(fontSize: number, lineHeight?: number) {
12 | const fontSizeWithUnit = `${fontSize}${DEFAULT_FONT_SIZE_UNIT}`
13 |
14 | return lineHeight ? `${fontSizeWithUnit}/${lineHeight}` : fontSizeWithUnit
15 | }
16 |
17 | /**
18 | * Create a `font` string from font properties.
19 | *
20 | * @param properties - The properties to create a `font` string from.
21 | * @param properties.fontFamily - A list of one or more font family names.
22 | * @param properties.fontSize - Set the size of the font.
23 | * @param [properties.fontStretch] - Select a normal, condensed, or expanded face from the font.
24 | * @param [properties.fontStyle] - Select a normal, italic, or oblique face from the font.
25 | * @param [properties.fontVariant] - Select variants from the font.
26 | * @param [properties.fontWeight] - Set the weight of the font.
27 | * @param [properties.lineHeight] - Define how tall a line of text should be.
28 | */
29 | export function getFontProperties({
30 | fontFamily,
31 | fontSize,
32 | fontStretch,
33 | fontStyle,
34 | fontVariant,
35 | fontWeight,
36 | lineHeight
37 | }: FontProperties) {
38 | if (!fontSize || !fontFamily) return
39 |
40 | const font = [
41 | fontStyle,
42 | fontVariant,
43 | fontWeight,
44 | fontStretch,
45 | getFontSizeWithLineHeight(fontSize, lineHeight),
46 | fontFamily
47 | ].filter(Boolean)
48 |
49 | return font.join(" ")
50 | }
51 |
--------------------------------------------------------------------------------
/packages/typometer/rollup.config.mjs:
--------------------------------------------------------------------------------
1 | import { createRequire } from "module"
2 | import resolve from "@rollup/plugin-node-resolve"
3 | import dts from "rollup-plugin-dts"
4 | import esbuild from "rollup-plugin-esbuild"
5 | import { worker } from "./rollup/worker.mjs"
6 |
7 | const pkg = createRequire(import.meta.url)("./package.json")
8 |
9 | export const options = {
10 | minify: true,
11 | format: "esm",
12 | target: "es2015",
13 | tsconfig: "tsconfig.build.json",
14 | define: {
15 | /**
16 | * Silencing superfluous `import.meta.url` warnings
17 | * since `Worker` instances are bundled and inlined.
18 | */
19 | "import.meta.url": `"import.meta.url"`
20 | }
21 | }
22 |
23 | const modernOptions = { ...options, target: "es2017" }
24 |
25 | export default [
26 | {
27 | input: pkg.source,
28 | plugins: [
29 | resolve({
30 | extensions: [".ts"]
31 | }),
32 | esbuild({ ...options, sourceMap: true }),
33 | worker({
34 | include: /measure-text\.ts/,
35 | options
36 | })
37 | ],
38 | output: [
39 | {
40 | file: pkg.main,
41 | format: "cjs",
42 | sourcemap: true
43 | },
44 | {
45 | file: pkg.module,
46 | format: "es",
47 | sourcemap: true
48 | }
49 | ]
50 | },
51 | {
52 | input: pkg.source,
53 | plugins: [
54 | resolve({
55 | extensions: [".ts"]
56 | }),
57 | esbuild({ ...modernOptions, sourceMap: true }),
58 | worker({
59 | include: /measure-text\.ts/,
60 | modernOptions
61 | })
62 | ],
63 | output: [
64 | {
65 | file: pkg.modern,
66 | format: "es",
67 | sourcemap: true
68 | }
69 | ]
70 | },
71 | {
72 | input: pkg.source,
73 | output: {
74 | file: pkg.types
75 | },
76 | plugins: [dts()]
77 | }
78 | ]
79 |
--------------------------------------------------------------------------------
/packages/typometer/tests/helpers.ts:
--------------------------------------------------------------------------------
1 | import type { Font } from "../src"
2 | import { getFont } from "../src/utils/get-font"
3 |
4 | /**
5 | * Check if two values are almost equal.
6 | *
7 | * @param a - The first value.
8 | * @param b - The second value.
9 | * @param [tolerance] - The tolerated difference between the two values.
10 | */
11 | export function almost(a: number, b: number, tolerance = Number.EPSILON) {
12 | return tolerance === 0 ? a === b : Math.abs(a - b) < tolerance
13 | }
14 |
15 | /**
16 | * Disable or replace a property from an object.
17 | *
18 | * @param object - The object containing the property.
19 | * @param property - The property's name.
20 | * @param [replacement] - An optional replacement value.
21 | * @returns A function to restore the original value.
22 | */
23 | export function affect(
24 | object: T,
25 | property: keyof T,
26 | replacement: any = undefined
27 | ) {
28 | const origin = object[property]
29 |
30 | object[property] = replacement
31 |
32 | return () => {
33 | object[property] = origin
34 | }
35 | }
36 |
37 | /**
38 | * Measure text within the DOM.
39 | *
40 | * @param text - The text to measure.
41 | * @param [font] - The font properties to set.
42 | */
43 | export function getComputedWidth(text: string, font?: Font) {
44 | const element = document.createElement("span")
45 | element.textContent = text
46 | document.body.append(element)
47 | element.style.font = getFont(font) ?? element.style.font
48 |
49 | return element.getBoundingClientRect().width
50 | }
51 |
52 | /**
53 | * Get the computed font within the DOM.
54 | *
55 | * @param [font] - The font properties to set.
56 | */
57 | export function getComputedFont(font?: Font) {
58 | const element = document.createElement("span")
59 | document.body.append(element)
60 | element.style.font = getFont(font) ?? element.style.font
61 |
62 | return window.getComputedStyle(element).getPropertyValue("font")
63 | }
64 |
--------------------------------------------------------------------------------
/packages/site/src/components/controls/Select.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx"
2 | import type { ComponentProps } from "react"
3 | import { forwardRef } from "react"
4 |
5 | export interface SelectProps extends ComponentProps<"div"> {
6 | /**
7 | * A set of `select` props.
8 | */
9 | selectProps?: ComponentProps<"select">
10 | }
11 |
12 | /**
13 | * A custom `select` component.
14 | *
15 | * @param props - A set of `div` props.
16 | * @param [props.children] - A set of children.
17 | * @param [props.className] - A list of one or more classes.
18 | * @param [props.selectProps] - A set of `select` props.
19 | */
20 | export const Select = forwardRef(
21 | ({ children, className, selectProps = {}, ...props }, ref) => (
22 |
29 |
43 | {children}
44 |
52 |
53 | )
54 | )
55 |
--------------------------------------------------------------------------------
/packages/typometer/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "typometer",
3 | "version": "2.1.2",
4 | "description": "Measure text asynchronously.",
5 | "author": "Marc Bouchenoire",
6 | "license": "MIT",
7 | "repository": "https://github.com/marcbouchenoire/typometer",
8 | "homepage": "https://typometer.marcbouchenoire.com",
9 | "keywords": [
10 | "measure",
11 | "text",
12 | "metrics",
13 | "canvas",
14 | "offscreen"
15 | ],
16 | "files": [
17 | "dist",
18 | "src"
19 | ],
20 | "sideEffects": false,
21 | "source": "./src/index.ts",
22 | "main": "./dist/index.js",
23 | "module": "./dist/index.module.js",
24 | "modern": "./dist/index.mjs",
25 | "exports": {
26 | ".": {
27 | "types": "./dist/index.d.ts",
28 | "module": "./dist/index.module.js",
29 | "import": "./dist/index.mjs",
30 | "default": "./dist/index.js"
31 | },
32 | "./package.json": "./package.json"
33 | },
34 | "types": "./dist/index.d.ts",
35 | "scripts": {
36 | "build": "rollup --config",
37 | "prettier": "prettier --write --loglevel silent --ignore-path .eslintignore",
38 | "lint": "pnpm lint:eslint && pnpm lint:tsc && pnpm lint:prettier",
39 | "lint:eslint": "eslint '**/*.{mjs,ts,tsx}' --fix",
40 | "lint:prettier": "pnpm prettier '**/*.{mjs,ts,tsx,json,md}'",
41 | "lint:tsc": "tsc --project tsconfig.json",
42 | "prepublishOnly": "pnpm build",
43 | "test": "wtr",
44 | "test:coverage": "pnpm test -- --coverage"
45 | },
46 | "devDependencies": {
47 | "@rollup/plugin-node-resolve": "^15.0.1",
48 | "@types/jest": "^29.2.5",
49 | "@types/offscreencanvas": "^2019.7.0",
50 | "@web/dev-server-esbuild": "^0.3.3",
51 | "@web/dev-server-rollup": "^0.3.19",
52 | "@web/test-runner": "^0.15.0",
53 | "@web/test-runner-puppeteer": "^0.11.0",
54 | "esbuild": "^0.16.15",
55 | "estree-walker": "^3.0.2",
56 | "magic-string": "^0.27.0",
57 | "rollup": "^3.9.1",
58 | "rollup-plugin-dts": "^5.1.1",
59 | "rollup-plugin-esbuild": "^5.0.0",
60 | "uvu": "^0.5.6"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/packages/site/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "site",
3 | "private": true,
4 | "scripts": {
5 | "dev": "next dev",
6 | "build": "next build",
7 | "prettier": "prettier --write --loglevel silent --ignore-path .eslintignore",
8 | "lint": "pnpm lint:eslint && pnpm lint:stylelint && pnpm lint:tsc && pnpm lint:prettier",
9 | "lint:eslint": "eslint '**/*.{cjs,mjs,ts,tsx}' --fix",
10 | "lint:prettier": "pnpm prettier '**/*.{cjs,mjs,ts,tsx,json,md,css}'",
11 | "lint:stylelint": "stylelint '**/*.css' --fix",
12 | "lint:tsc": "tsc --project tsconfig.json",
13 | "postinstall": "node src/scripts/postinstall.mjs"
14 | },
15 | "dependencies": {
16 | "@radix-ui/react-label": "^2.0.0",
17 | "@radix-ui/react-slider": "^1.1.0",
18 | "@radix-ui/react-toggle-group": "^1.0.1",
19 | "clsx": "^1.2.1",
20 | "framer-motion": "^8.2.4",
21 | "just-debounce-it": "^3.2.0",
22 | "mdast-util-to-string": "^3.1.0",
23 | "next": "^13.1.1",
24 | "next-themes": "^0.2.1",
25 | "react": "^18.2.0",
26 | "react-dom": "^18.2.0",
27 | "rehype-autolink-headings": "^6.1.1",
28 | "rehype-prism-plus": "^1.5.0",
29 | "rehype-raw": "^6.1.1",
30 | "rehype-slug": "^5.1.0",
31 | "rehype-stringify": "^9.0.3",
32 | "remark-parse": "^10.0.1",
33 | "remark-rehype": "^10.1.0",
34 | "typometer": "workspace:*",
35 | "unified": "^10.1.2",
36 | "unist-util-remove": "^3.1.0",
37 | "unist-util-select": "^4.0.2",
38 | "use-subsequent-effect": "^1.2.1"
39 | },
40 | "devDependencies": {
41 | "@marcbouchenoire/stylelint-config": "^1.5.0",
42 | "@tailwindcss/typography": "^0.5.8",
43 | "@types/mdast": "^3.0.10",
44 | "@types/react": "^18.0.26",
45 | "@types/react-dom": "^18.0.10",
46 | "@types/unist": "^2.0.6",
47 | "autoprefixer": "^10.4.13",
48 | "postcss": "^8.4.21",
49 | "postcss-nesting": "^10.2.0",
50 | "prettier-plugin-tailwindcss": "^0.2.1",
51 | "stylelint": "^14.16.1",
52 | "tailwindcss": "^3.2.4",
53 | "type-fest": "^3.5.7",
54 | "write-json-file": "^5.0.0"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/packages/site/src/components/layout/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx"
2 | import Image from "next/image"
3 | import type { ComponentProps } from "react"
4 | import portrait from "../../../public/portrait.jpg"
5 | import { useData } from "../../hooks/use-data"
6 |
7 | /**
8 | * A footer section with credits.
9 | *
10 | * @param props - A set of `footer` props.
11 | * @param [props.className] - A list of one or more classes.
12 | */
13 | export function Footer({ className, ...props }: ComponentProps<"footer">) {
14 | const { date } = useData()
15 |
16 | return (
17 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/packages/typometer/src/measure-text.ts:
--------------------------------------------------------------------------------
1 | import { isUndefined } from "./guards"
2 | import type { WorkerMessage } from "./measure-text.worker"
3 | import type { Font, SerializedTextMetrics } from "./types"
4 | import { getFont } from "./utils/get-font"
5 | import { normalizeString } from "./utils/normalize-string"
6 | import { sendMessage } from "./utils/send-message"
7 | import { serializeTextMetrics } from "./utils/serialize-text-metrics"
8 | import {
9 | supportsCanvas,
10 | supportsOffscreenCanvas
11 | } from "./utils/supports-canvas"
12 |
13 | let defaultFont: string
14 | let context: CanvasRenderingContext2D
15 | let worker: Worker
16 |
17 | /**
18 | * Access a 2D rendering context by creating one if it doesn't exist yet.
19 | */
20 | function getContext() {
21 | if (isUndefined(context)) {
22 | const canvas = document.createElement("canvas")
23 |
24 | canvas.width = 1
25 | canvas.height = 1
26 |
27 | context = canvas.getContext("2d") as CanvasRenderingContext2D
28 | defaultFont = context.font
29 | }
30 |
31 | return context
32 | }
33 |
34 | /**
35 | * Access an offscreen text measuring `Worker` by creating one if it doesn't exist yet.
36 | */
37 | function getWorker() {
38 | if (isUndefined(worker)) {
39 | worker = new Worker(new URL("measure-text.worker.ts", import.meta.url))
40 | }
41 |
42 | return worker
43 | }
44 |
45 | /**
46 | * Measure text using an `OffscreenCanvas` or an `HTMLCanvasElement`.
47 | *
48 | * @param text - The text to measure.
49 | * @param [font] - The font properties to set.
50 | */
51 | export async function measureText(
52 | text: string,
53 | font?: Font
54 | ): Promise {
55 | const normalizedText = normalizeString(text)
56 | const resolvedFont = getFont(font)
57 |
58 | if (supportsOffscreenCanvas()) {
59 | const worker = getWorker()
60 |
61 | return await sendMessage(worker, [
62 | normalizedText,
63 | resolvedFont
64 | ])
65 | } else if (supportsCanvas()) {
66 | const context = getContext()
67 | context.font = resolvedFont ?? defaultFont
68 | const metrics = context.measureText(normalizedText)
69 |
70 | return serializeTextMetrics(metrics)
71 | } else {
72 | throw new Error(
73 | "The current environment doesn't seem to support the Canvas API."
74 | )
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/packages/site/src/plugins/remark/filter-headings.ts:
--------------------------------------------------------------------------------
1 | import type { Heading as HeadingNode } from "mdast"
2 | import { toString } from "mdast-util-to-string"
3 | import type { Plugin } from "unified"
4 | import type { Node, Parent } from "unist"
5 | import { isSomething } from "../../guards"
6 |
7 | interface Heading {
8 | /**
9 | * The heading's scale.
10 | */
11 | depth?: 1 | 2 | 3 | 4 | 5 | 6
12 |
13 | /**
14 | * The heading's content.
15 | */
16 | value?: string
17 | }
18 |
19 | interface Options {
20 | /**
21 | * A list of headings to exclude.
22 | */
23 | exclude?: Heading[]
24 |
25 | /**
26 | * A list of headings to include.
27 | */
28 | include?: Heading[]
29 | }
30 |
31 | /**
32 | * Whether the node is a `HeadingNode`.
33 | *
34 | * @param node - The heading to check.
35 | */
36 | function isHeadingNode(node: Node): node is HeadingNode {
37 | return node.type === "heading"
38 | }
39 |
40 | /**
41 | * A plugin to filter out headings and their sections.
42 | *
43 | * @param [options] - An optional set of settings.
44 | * @param [options.include] - A list of headings to include.
45 | * @param [options.exclude] - A list of headings to exclude.
46 | */
47 | const filterHeadings: Plugin<[Options | undefined], Parent> = (options) => {
48 | return (tree) => {
49 | const included = options?.include ?? []
50 | const excluded = options?.exclude ?? []
51 | let include = true
52 |
53 | tree.children = tree.children
54 | .map((node) => {
55 | if (isHeadingNode(node)) {
56 | include = true
57 |
58 | for (const { depth, value } of excluded) {
59 | if (
60 | (isSomething(depth) && node.depth === depth) ||
61 | (isSomething(value) && toString(node) === value)
62 | ) {
63 | include = false
64 | }
65 | }
66 |
67 | for (const { depth, value } of included) {
68 | if (
69 | (isSomething(depth) && node.depth === depth) ||
70 | (isSomething(value) && toString(node) === value)
71 | ) {
72 | include = true
73 | }
74 | }
75 | }
76 |
77 | return (include ? node : undefined) as typeof node
78 | })
79 | .filter((node) => isSomething(node))
80 | }
81 | }
82 |
83 | export default filterHeadings
84 |
--------------------------------------------------------------------------------
/packages/typometer/tests/utils/get-font-properties.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-ts-comment */
2 |
3 | import * as assert from "uvu/assert"
4 | import { getFontProperties } from "../../src/utils/get-font-properties"
5 | import {
6 | family,
7 | line,
8 | size,
9 | stretch,
10 | style,
11 | variant,
12 | weight
13 | } from "../constants"
14 |
15 | describe("getFontProperties", () => {
16 | it("should combine font properties into a font string", () => {
17 | const font = getFontProperties({
18 | fontFamily: family,
19 | fontSize: size,
20 | fontStretch: stretch,
21 | fontStyle: style,
22 | fontVariant: variant,
23 | fontWeight: weight
24 | })
25 |
26 | const paragraph = document.createElement("p")
27 | const styles = window.getComputedStyle(paragraph)
28 | document.body.append(paragraph)
29 |
30 | if (font) {
31 | paragraph.style.font = font
32 | }
33 |
34 | assert.equal(styles.getPropertyValue("font-family"), family)
35 | assert.equal(styles.getPropertyValue("font-size"), `${size}px`)
36 | assert.equal(styles.getPropertyValue("font-stretch"), "75%")
37 | assert.equal(styles.getPropertyValue("font-style"), style)
38 | assert.equal(styles.getPropertyValue("font-variant"), variant)
39 | assert.equal(styles.getPropertyValue("font-weight"), `${weight}`)
40 | })
41 |
42 | it("should correctly combine a given font size and line height", () => {
43 | const font = getFontProperties({
44 | fontFamily: family,
45 | fontSize: size,
46 | lineHeight: line
47 | })
48 |
49 | const paragraph = document.createElement("p")
50 | const styles = window.getComputedStyle(paragraph)
51 | document.body.append(paragraph)
52 |
53 | if (font) {
54 | paragraph.style.font = font
55 | }
56 |
57 | assert.equal(styles.getPropertyValue("line-height"), `${size * line}px`)
58 | })
59 |
60 | it("should return undefined if font family and/or font size aren't provided", () => {
61 | // @ts-ignore
62 | const fontFamily = getFontProperties({
63 | fontFamily: family
64 | })
65 | // @ts-ignore
66 | const fontSize = getFontProperties({
67 | fontSize: size
68 | })
69 | // @ts-ignore
70 | const neither = getFontProperties({ fontStyle: style })
71 |
72 | assert.equal(fontFamily, undefined)
73 | assert.equal(fontSize, undefined)
74 | assert.equal(neither, undefined)
75 | })
76 | })
77 |
--------------------------------------------------------------------------------
/packages/typometer/rollup/worker.mjs:
--------------------------------------------------------------------------------
1 | import path from "path"
2 | import { build } from "esbuild"
3 | import { asyncWalk } from "estree-walker"
4 | import MagicString from "magic-string"
5 |
6 | /**
7 | * Bundle and inline `Worker` instances.
8 | *
9 | * @param [options] - An optional set of settings.
10 | * @param [options.include] - Which files to look for.
11 | * @param [options.options] - Which esbuild options to set when bundling.
12 | */
13 | export function worker({ include = /./, options = {} }) {
14 | return {
15 | name: "rollup-plugin-worker",
16 | async transform(code, module) {
17 | if (!include.test(module)) {
18 | return null
19 | }
20 |
21 | const ast = this.parse(code)
22 | const magicString = new MagicString(code)
23 | let withChanges = false
24 |
25 | await asyncWalk(ast, {
26 | enter: async (node) => {
27 | const isWorkerNode =
28 | node.type === "NewExpression" &&
29 | node.callee?.name === "Worker" &&
30 | node.arguments?.[0]?.type === "NewExpression" &&
31 | node.arguments?.[0]?.callee?.name === "URL" &&
32 | node.arguments?.[0]?.arguments?.[0]?.type === "Literal"
33 |
34 | if (!isWorkerNode) return
35 |
36 | const url = node.arguments?.[0]
37 | const worker = path.basename(url.arguments[0].value)
38 | const entry = path.resolve(path.dirname(module), worker)
39 |
40 | try {
41 | const {
42 | errors,
43 | outputFiles: [output]
44 | } = await build({
45 | ...options,
46 | write: false,
47 | bundle: true,
48 | minify: true,
49 | entryPoints: [entry]
50 | })
51 |
52 | const code = output?.text?.replace(/\n|\r/g, "")
53 |
54 | if (!code) {
55 | for (const error of errors) {
56 | throw new Error(error)
57 | }
58 | }
59 |
60 | withChanges = true
61 | magicString.overwrite(
62 | url.start,
63 | url.end,
64 | `URL.createObjectURL(new Blob([\`${code}\`], { type: "application/javascript" }))`
65 | )
66 | } catch (error) {
67 | this.warn(error)
68 | }
69 | }
70 | })
71 |
72 | return {
73 | code: withChanges ? magicString.toString() : code,
74 | map: withChanges ? magicString.generateMap({ hires: true }) : null
75 | }
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/packages/site/src/components/controls/Slider.tsx:
--------------------------------------------------------------------------------
1 | import type { SliderProps as DefaultSliderProps } from "@radix-ui/react-slider"
2 | import { Range, Root, Thumb, Track } from "@radix-ui/react-slider"
3 | import { clsx } from "clsx"
4 | import { forwardRef, useCallback, useMemo } from "react"
5 | import { isNumber } from "../../guards"
6 |
7 | export interface SliderProps
8 | extends Omit {
9 | /**
10 | * The value of the slider when initially rendered.
11 | */
12 | defaultValue?: number
13 |
14 | /**
15 | * A function invoked when the value changes.
16 | */
17 | onValueChange?(value: number): void
18 |
19 | /**
20 | * The controlled value of the slider.
21 | */
22 | value?: number
23 | }
24 |
25 | /**
26 | * A slider component.
27 | *
28 | * @param [props] - A set of props.
29 | * @param [props.value] - The controlled value of the slider.
30 | * @param [props.defaultValue] - The value of the slider when initially rendered.
31 | * @param [props.onValueChange] - A function invoked when the value changes.
32 | * @param [props.className] - A list of one or more classes.
33 | * @param [props.id] - A unique identifier.
34 | */
35 | export const Slider = forwardRef(
36 | ({ value, defaultValue, onValueChange, className, id, ...props }, ref) => {
37 | const values = useMemo(
38 | () => (isNumber(value) ? [value] : undefined),
39 | [value]
40 | )
41 | const defaultValues = useMemo(
42 | () => (isNumber(defaultValue) ? [defaultValue] : undefined),
43 | [defaultValue]
44 | )
45 |
46 | const handleValueChange = useCallback(
47 | ([value]: number[]) => {
48 | onValueChange?.(value)
49 | },
50 | [onValueChange]
51 | )
52 |
53 | return (
54 |
65 |
68 |
72 |
73 | )
74 | }
75 | )
76 |
--------------------------------------------------------------------------------
/packages/site/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { readFile } from "fs/promises"
2 | import type { GetStaticProps } from "next"
3 | import rehypeAutolinkHeadings from "rehype-autolink-headings"
4 | import rehypePrism from "rehype-prism-plus"
5 | import rehypeRaw from "rehype-raw"
6 | import rehypeSlug from "rehype-slug"
7 | import rehypeStringify from "rehype-stringify"
8 | import remarkParse from "remark-parse"
9 | import remarkRehype from "remark-rehype"
10 | import type { Plugin } from "unified"
11 | import { unified } from "unified"
12 | import { Editor } from "../components/sections/Editor"
13 | import { Introduction } from "../components/sections/Introduction"
14 | import rehypeRemoveImages from "../plugins/rehype/remove-images"
15 | import remarkFilterHeadings from "../plugins/remark/filter-headings"
16 | import remarkFindNode from "../plugins/remark/find-node"
17 |
18 | interface Props {
19 | /**
20 | * The filtered README content formatted as HTML.
21 | */
22 | content: string
23 |
24 | /**
25 | * The README list of features formatted as HTML.
26 | */
27 | features: string
28 | }
29 |
30 | /**
31 | * The index page component.
32 | *
33 | * @param props - A set of props.
34 | * @param props.content - The filtered README content formatted as HTML.
35 | * @param props.features - The README list of features formatted as HTML.
36 | */
37 | function Page({ content, features }: Props) {
38 | return (
39 |
40 |
41 |
42 |
46 |
47 | )
48 | }
49 |
50 | export default Page
51 |
52 | export const getStaticProps: GetStaticProps = async () => {
53 | const file = await readFile("../../packages/typometer/README.md")
54 | const processor = unified().use(remarkParse as Plugin)
55 |
56 | const features = await processor()
57 | .use(remarkFindNode, "list")
58 | .use(remarkRehype, { allowDangerousHtml: true })
59 | .use(rehypeRaw)
60 | .use(rehypeSlug)
61 | .use(rehypeStringify as Plugin)
62 | .process(file)
63 |
64 | const content = await processor()
65 | .use(remarkFilterHeadings, { exclude: [{ depth: 1 }] })
66 | .use(remarkRehype, { allowDangerousHtml: true })
67 | .use(rehypeRaw)
68 | .use(rehypeRemoveImages)
69 | .use(rehypePrism)
70 | .use(rehypeSlug)
71 | .use(rehypeAutolinkHeadings, { content: [] })
72 | .use(rehypeStringify as Plugin)
73 | .process(file)
74 |
75 | return {
76 | props: {
77 | content: String(content.value),
78 | features: String(features.value)
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/packages/site/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeProvider } from "next-themes"
2 | import type { AppProps } from "next/app"
3 | import Head from "next/head"
4 | import { Footer } from "../components/layout/Footer"
5 | import { Header } from "../components/layout/Header"
6 | import "../styles/fonts.css"
7 | import "../styles/main.css"
8 |
9 | /**
10 | * A custom `App` component, used to initialize pages.
11 | *
12 | * @param props - A set of props.
13 | * @param props.Component - The active page component.
14 | * @param props.pageProps - The initial props preloaded for the page.
15 | */
16 | function App({ Component, pageProps }: AppProps) {
17 | return (
18 |
19 |
20 | Typometer
21 |
22 |
23 |
24 |
25 |
26 |
30 |
31 |
35 |
39 |
43 |
44 |
45 |
49 |
53 |
54 |
55 |
56 |
57 |
62 |
63 |
64 |
65 |
66 | )
67 | }
68 |
69 | export default App
70 |
--------------------------------------------------------------------------------
/packages/site/src/hooks/use-system-theme.ts:
--------------------------------------------------------------------------------
1 | import { useTheme } from "next-themes"
2 | import { useCallback, useEffect, useMemo } from "react"
3 |
4 | type Theme = "dark" | "light"
5 |
6 | type SystemTheme = Theme | "system"
7 |
8 | type ResolvedTheme = Theme | undefined
9 |
10 | type MediaQueryListCallback = (event: MediaQueryListEvent) => void
11 |
12 | /**
13 | * Create an event listener on a media query.
14 | *
15 | * @param mediaQuery - The media query to listen to.
16 | * @param callback - The function to invoke on changes.
17 | */
18 | function addMediaQueryListener(
19 | mediaQuery: MediaQueryList,
20 | callback: MediaQueryListCallback
21 | ) {
22 | return mediaQuery.addEventListener
23 | ? mediaQuery.addEventListener("change", callback)
24 | : mediaQuery.addListener(callback)
25 | }
26 |
27 | /**
28 | * Remove an event listener from a media query.
29 | *
30 | * @param mediaQuery - The listened to media query.
31 | * @param callback - The function invoked on changes.
32 | */
33 | function removeMediaQueryListener(
34 | mediaQuery: MediaQueryList,
35 | callback: MediaQueryListCallback
36 | ) {
37 | return mediaQuery.removeEventListener
38 | ? mediaQuery.removeEventListener("change", callback)
39 | : mediaQuery.removeListener(callback)
40 | }
41 |
42 | /**
43 | * Create a `prefers-color-scheme` media query.
44 | *
45 | * @param theme - The preferred color scheme to match.
46 | */
47 | function getThemeMediaQuery(theme: Theme) {
48 | return window.matchMedia(`(prefers-color-scheme: ${theme})`)
49 | }
50 |
51 | /**
52 | * Get the current theme and a function to toggle it.
53 | */
54 | export function useSystemTheme(): [ResolvedTheme, () => void] {
55 | const { theme, resolvedTheme, setTheme } = useTheme()
56 | const isSystemTheme = useMemo(
57 | () => (theme as SystemTheme) === "system",
58 | [theme]
59 | )
60 |
61 | const resolvedSystemTheme: ResolvedTheme = useMemo(() => {
62 | return !resolvedTheme || resolvedTheme === "system"
63 | ? undefined
64 | : (resolvedTheme as Theme)
65 | }, [resolvedTheme])
66 |
67 | const toggleSystemTheme = useCallback(() => {
68 | const toggledTheme: Theme =
69 | (resolvedTheme as Theme) === "dark" ? "light" : "dark"
70 | const matchesSystem = getThemeMediaQuery(toggledTheme).matches
71 |
72 | setTheme(matchesSystem ? "system" : toggledTheme)
73 | }, [resolvedTheme, setTheme])
74 |
75 | const handleDarkMediaQueryChange: MediaQueryListCallback = useCallback(() => {
76 | setTheme("system")
77 | }, [setTheme])
78 |
79 | useEffect(() => {
80 | if (isSystemTheme) return
81 |
82 | const darkMediaQuery = getThemeMediaQuery("dark")
83 |
84 | addMediaQueryListener(darkMediaQuery, handleDarkMediaQueryChange)
85 |
86 | return () => {
87 | removeMediaQueryListener(darkMediaQuery, handleDarkMediaQueryChange)
88 | }
89 | }, [isSystemTheme, handleDarkMediaQueryChange])
90 |
91 | return [resolvedSystemTheme, toggleSystemTheme]
92 | }
93 |
--------------------------------------------------------------------------------
/packages/site/src/components/sections/Introduction.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx"
2 | import Image from "next/image"
3 | import type { ComponentProps } from "react"
4 | import logo from "../../../public/logo.svg"
5 | import { InstallButton } from "../controls/InstallButton"
6 |
7 | interface Props extends ComponentProps<"section"> {
8 | /**
9 | * The README list of features formatted as HTML.
10 | */
11 | features: string
12 | }
13 |
14 | /**
15 | * A section introducing the library.
16 | *
17 | * @param props - A set of `section` props.
18 | * @param props.features - The README list of features formatted as HTML.
19 | * @param [props.className] - A list of one or more classes.
20 | */
21 | export function Introduction({ features, className, ...props }: Props) {
22 | return (
23 |
24 |
25 |
26 |
27 |
28 | Measure text asynchronously.
29 |
30 |
34 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/packages/site/src/components/controls/SegmentedControl.tsx:
--------------------------------------------------------------------------------
1 | import type { ToggleGroupSingleProps } from "@radix-ui/react-toggle-group"
2 | import { Item, Root } from "@radix-ui/react-toggle-group"
3 | import { clsx } from "clsx"
4 | import type { Transition } from "framer-motion"
5 | import { LayoutGroup, motion } from "framer-motion"
6 | import type { ReactChild } from "react"
7 | import { forwardRef, useId } from "react"
8 |
9 | export interface SegmentedControlProps
10 | extends Omit {
11 | /**
12 | * A list of option labels.
13 | */
14 | labels?: ReactChild[]
15 |
16 | /**
17 | * A list of option values.
18 | */
19 | options: string[]
20 | }
21 |
22 | const transition: Transition = {
23 | type: "spring",
24 | stiffness: 260,
25 | damping: 28
26 | }
27 |
28 | /**
29 | * A set of two or more mutually exclusive segments.
30 | *
31 | * @param props - A set of props.
32 | * @param props.options - A list of option values.
33 | * @param [props.labels] - A list of option labels.
34 | * @param [props.value] - The default value.
35 | * @param [props.className] - A list of one or more classes.
36 | * @param [props.id] - A unique identifier.
37 | */
38 | export const SegmentedControl = forwardRef<
39 | HTMLDivElement,
40 | SegmentedControlProps
41 | >(({ options, labels = [], value, className, ...props }, ref) => {
42 | const layoutId = useId()
43 |
44 | return (
45 |
46 |
47 |
55 | {options.map((option, index, options) => {
56 | const isActive = option === value
57 | const isAfterActive = options[index - 1] === value
58 |
59 | return (
60 | -
61 |
71 |
72 | {labels[index] ?? option}
73 |
74 | {isActive && (
75 |
81 | )}
82 |
83 |
84 | )
85 | })}
86 |
87 |
88 |
89 | )
90 | })
91 |
--------------------------------------------------------------------------------
/packages/typometer/tests/index.test.ts:
--------------------------------------------------------------------------------
1 | import * as assert from "uvu/assert"
2 | import { typometer } from "../src"
3 | import type { FontProperties } from "../src/types"
4 | import { string } from "./constants"
5 | import { affect, almost, getComputedWidth } from "./helpers"
6 |
7 | describe("typometer", () => {
8 | const tolerance = 0.05
9 | const font = "italic small-caps 500 16px/2 cursive"
10 | const properties: FontProperties = {
11 | fontFamily: "cursive",
12 | fontSize: 16,
13 | fontStyle: "italic",
14 | fontVariant: "small-caps",
15 | fontWeight: 500,
16 | lineHeight: 2
17 | }
18 |
19 | it("should measure text", async () => {
20 | const { width } = await typometer(string, properties)
21 |
22 | assert.equal(
23 | almost(width, getComputedWidth(string, properties), tolerance),
24 | true
25 | )
26 | })
27 |
28 | it("should measure text with an HTMLCanvasElement when OffscreenCanvas isn't supported", async () => {
29 | const restoreOffscreenCanvas = affect(window, "OffscreenCanvas")
30 |
31 | const { width } = await typometer(string, properties)
32 |
33 | assert.equal(
34 | almost(width, getComputedWidth(string, properties), tolerance),
35 | true
36 | )
37 |
38 | restoreOffscreenCanvas()
39 | })
40 |
41 | it("shouldn't override the default font", async () => {
42 | const canvas = document.createElement("canvas")
43 | const context = canvas.getContext("2d")
44 | const defaultFont = context?.font ?? ""
45 |
46 | const restoreOffscreenCanvas = affect(window, "OffscreenCanvas")
47 |
48 | const { width } = await typometer(string)
49 |
50 | restoreOffscreenCanvas()
51 |
52 | const { width: widthOffscreen } = await typometer(string)
53 |
54 | assert.equal(
55 | almost(width, getComputedWidth(string, defaultFont), tolerance),
56 | true
57 | )
58 | assert.equal(
59 | almost(widthOffscreen, getComputedWidth(string, defaultFont), tolerance),
60 | true
61 | )
62 | })
63 |
64 | it("should throw when HTMLCanvasElement or OffscreenCanvas aren't supported", async () => {
65 | const restoreHTMLCanvasElement = affect(window, "HTMLCanvasElement")
66 | const restoreOffscreenCanvas = affect(window, "OffscreenCanvas")
67 |
68 | try {
69 | await typometer(string, properties)
70 | assert.unreachable()
71 | } catch (error) {
72 | assert.instance(error, Error)
73 | }
74 |
75 | restoreHTMLCanvasElement()
76 | restoreOffscreenCanvas()
77 | })
78 |
79 | it("should measure an array of text", async () => {
80 | const letters = [...string]
81 | const metrics = await typometer(letters, properties)
82 |
83 | letters.map((letter, index) => {
84 | assert.equal(
85 | almost(
86 | metrics[index].width,
87 | getComputedWidth(letter, properties),
88 | tolerance
89 | ),
90 | true
91 | )
92 | })
93 | })
94 |
95 | it("should measure text given a font string", async () => {
96 | const { width } = await typometer(string, font)
97 |
98 | assert.equal(
99 | almost(width, getComputedWidth(string, properties), tolerance),
100 | true
101 | )
102 | })
103 |
104 | it("should measure text given a CSSStyleDeclaration", async () => {
105 | const element = document.createElement("span")
106 | element.style.setProperty("font", font)
107 | document.body.append(element)
108 |
109 | const { width } = await typometer(string, window.getComputedStyle(element))
110 |
111 | assert.equal(
112 | almost(width, getComputedWidth(string, properties), tolerance),
113 | true
114 | )
115 | })
116 | })
117 |
--------------------------------------------------------------------------------
/packages/site/src/components/miscellaneous/ScrambledText.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx"
2 | import type { ComponentProps } from "react"
3 | import { memo, useMemo, useReducer } from "react"
4 | import type { SetOptional } from "type-fest"
5 | import { useInterval } from "../../hooks/use-interval"
6 | import { random } from "../../utils/random"
7 |
8 | const ALPHABET =
9 | "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%&()*+-./:;<=>?@[]^_{}~"
10 | const SIZE_PERCENTAGE = 0.8
11 | const DEFAULT_INTERVAL = 30
12 | const INTERVAL_RANDOMNESS = 10
13 |
14 | interface Props extends Omit, "children"> {
15 | /**
16 | * The string to start from.
17 | */
18 | from: string
19 |
20 | /**
21 | * The update interval in milliseconds.
22 | */
23 | interval?: number
24 |
25 | /**
26 | * The string to end with.
27 | */
28 | to: string
29 | }
30 |
31 | /**
32 | * Scramble a string while keeping whitespace.
33 | *
34 | * @param value - The string to scramble.
35 | */
36 | function scrambleText(value: string) {
37 | return [...value]
38 | .map((character) => {
39 | if (/^\s$/.test(character)) {
40 | return character
41 | }
42 |
43 | return ALPHABET[Math.floor(Math.random() * ALPHABET.length)]
44 | })
45 | .join("")
46 | }
47 |
48 | /**
49 | * A component that transitions from one
50 | * string to another using scrambled text.
51 | *
52 | * @param props - A set of `span` props.
53 | * @param props.from - The string to start from.
54 | * @param props.to - The string to end with.
55 | * @param [props.interval] - The update interval in milliseconds.
56 | * @param [props.className] - A list of one or more classes.
57 | */
58 | function StrictScrambledText({
59 | from,
60 | to,
61 | interval = DEFAULT_INTERVAL,
62 | className,
63 | ...props
64 | }: Props) {
65 | const size = useMemo(() => {
66 | const average = (from.length + to.length) / 2
67 |
68 | return Math.round(average * SIZE_PERCENTAGE)
69 | }, [from, to])
70 | const minimum = useMemo(() => -size, [size])
71 | const maximum = useMemo(() => Math.max(from.length, to.length), [from, to])
72 | const [index, increment] = useReducer((index: number) => index + 1, minimum)
73 | const scrambledText = useMemo(() => {
74 | const longest = from.length > to.length ? from : to
75 |
76 | const leading = to.slice(0, Math.max(0, index))
77 | const scrambled = scrambleText(
78 | longest.slice(Math.max(0, index), index + size)
79 | )
80 | const trailing = from.slice(index + size, maximum)
81 |
82 | return leading + scrambled + trailing
83 | }, [from, index, size, to, maximum])
84 | const randomizedInterval = useMemo(() => {
85 | return interval + random(-INTERVAL_RANDOMNESS, INTERVAL_RANDOMNESS)
86 | }, [index, interval]) // eslint-disable-line react-hooks/exhaustive-deps
87 |
88 | useInterval(
89 | increment,
90 | index > Math.max(from.length, to.length) ? null : randomizedInterval
91 | )
92 |
93 | return (
94 |
95 | {scrambledText}
96 |
97 | )
98 | }
99 |
100 | /**
101 | * A component that transitions from one
102 | * string to another using scrambled text.
103 | *
104 | * @param props - A set of `span` props.
105 | * @param props.from - The string to start from.
106 | * @param props.to - The string to end with.
107 | * @param [props.interval] - The update interval in milliseconds.
108 | * @param [props.className] - A list of one or more classes.
109 | */
110 | export const ScrambledText = memo(
111 | ({
112 | from,
113 | to,
114 | interval = DEFAULT_INTERVAL,
115 | ...props
116 | }: SetOptional) => {
117 | return from ? (
118 |
119 | ) : (
120 | {to}
121 | )
122 | }
123 | )
124 |
--------------------------------------------------------------------------------
/packages/typometer/tests/guards.test.ts:
--------------------------------------------------------------------------------
1 | import * as assert from "uvu/assert"
2 | import {
3 | isArray,
4 | isCSSStyleDeclaration,
5 | isFunction,
6 | isNumber,
7 | isString,
8 | isUndefined
9 | } from "../src/guards"
10 | import {
11 | array,
12 | boolean,
13 | fun,
14 | map,
15 | number,
16 | object,
17 | set,
18 | string
19 | } from "./constants"
20 |
21 | describe("isArray", () => {
22 | it("should return true for arrays", () => {
23 | assert.equal(isArray(array), true)
24 | })
25 |
26 | it("should return false for any other types", () => {
27 | assert.equal(isArray(boolean), false)
28 | assert.equal(isArray(fun), false)
29 | assert.equal(isArray(map), false)
30 | assert.equal(isArray(number), false)
31 | assert.equal(isArray(object), false)
32 | assert.equal(isArray(set), false)
33 | assert.equal(isArray(string), false)
34 | })
35 | })
36 |
37 | describe("isUndefined", () => {
38 | it("should return true for undefined", () => {
39 | assert.equal(isUndefined(undefined), true) // eslint-disable-line unicorn/no-useless-undefined
40 | })
41 |
42 | it("should return false for any other types", () => {
43 | assert.equal(isUndefined(array), false)
44 | assert.equal(isUndefined(boolean), false)
45 | assert.equal(isUndefined(fun), false)
46 | assert.equal(isUndefined(map), false)
47 | assert.equal(isUndefined(number), false)
48 | assert.equal(isUndefined(object), false)
49 | assert.equal(isUndefined(set), false)
50 | assert.equal(isUndefined(string), false)
51 | })
52 | })
53 |
54 | describe("isNumber", () => {
55 | it("should return true for numbers", () => {
56 | assert.equal(isNumber(number), true)
57 | })
58 |
59 | it("should return false for any other types", () => {
60 | assert.equal(isNumber(array), false)
61 | assert.equal(isNumber(boolean), false)
62 | assert.equal(isNumber(fun), false)
63 | assert.equal(isNumber(map), false)
64 | assert.equal(isNumber(object), false)
65 | assert.equal(isNumber(set), false)
66 | assert.equal(isNumber(string), false)
67 | })
68 | })
69 |
70 | describe("isString", () => {
71 | it("should return true for numbers", () => {
72 | assert.equal(isString(string), true)
73 | })
74 |
75 | it("should return false for any other types", () => {
76 | assert.equal(isString(array), false)
77 | assert.equal(isString(boolean), false)
78 | assert.equal(isString(fun), false)
79 | assert.equal(isString(map), false)
80 | assert.equal(isString(number), false)
81 | assert.equal(isString(object), false)
82 | assert.equal(isString(set), false)
83 | })
84 | })
85 |
86 | describe("isFunction", () => {
87 | it("should return true for functions", () => {
88 | assert.equal(isFunction(fun), true)
89 | })
90 |
91 | it("should return false for any other types", () => {
92 | assert.equal(isFunction(array), false)
93 | assert.equal(isFunction(boolean), false)
94 | assert.equal(isFunction(map), false)
95 | assert.equal(isFunction(number), false)
96 | assert.equal(isFunction(object), false)
97 | assert.equal(isFunction(set), false)
98 | assert.equal(isFunction(string), false)
99 | })
100 | })
101 |
102 | describe("isCSSStyleDeclaration", () => {
103 | const element = document.createElement("div")
104 | document.body.append(element)
105 |
106 | it("should return true for CSSStyleDeclaration", () => {
107 | assert.equal(isCSSStyleDeclaration(window.getComputedStyle(element)), true)
108 | })
109 |
110 | it("should return false for any other types", () => {
111 | assert.equal(isCSSStyleDeclaration(array), false)
112 | assert.equal(isCSSStyleDeclaration(boolean), false)
113 | assert.equal(isCSSStyleDeclaration(fun), false)
114 | assert.equal(isCSSStyleDeclaration(map), false)
115 | assert.equal(isCSSStyleDeclaration(number), false)
116 | assert.equal(isCSSStyleDeclaration(object), false)
117 | assert.equal(isCSSStyleDeclaration(set), false)
118 | assert.equal(isCSSStyleDeclaration(string), false)
119 | })
120 | })
121 |
--------------------------------------------------------------------------------
/packages/site/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | const defaultTheme = require("tailwindcss/defaultTheme")
2 | const plugin = require("tailwindcss/plugin")
3 |
4 | const paddingSafe = plugin(({ addUtilities, config, e }) => {
5 | const paddings = config("theme.padding", {})
6 | const variants = config("variants.padding", {})
7 |
8 | const utilities = Object.entries(paddings).flatMap(([modifier, size]) => ({
9 | [`.${e(`p-${modifier}-safe`)}`]: {
10 | "padding-top": `max(${size}, env(safe-area-inset-top))`,
11 | "padding-bottom": `max(${size}, env(safe-area-inset-bottom))`,
12 | "padding-left": `max(${size}, env(safe-area-inset-left))`,
13 | "padding-right": `max(${size}, env(safe-area-inset-right))`
14 | },
15 | [`.${e(`py-${modifier}-safe`)}`]: {
16 | "padding-top": `max(${size}, env(safe-area-inset-top))`,
17 | "padding-bottom": `max(${size}, env(safe-area-inset-bottom))`
18 | },
19 | [`.${e(`px-${modifier}-safe`)}`]: {
20 | "padding-left": `max(${size}, env(safe-area-inset-left))`,
21 | "padding-right": `max(${size}, env(safe-area-inset-right))`
22 | },
23 | [`.${e(`pt-${modifier}-safe`)}`]: {
24 | "padding-top": `max(${size}, env(safe-area-inset-top))`
25 | },
26 | [`.${e(`pr-${modifier}-safe`)}`]: {
27 | "padding-right": `max(${size}, env(safe-area-inset-right))`
28 | },
29 | [`.${e(`pb-${modifier}-safe`)}`]: {
30 | "padding-bottom": `max(${size}, env(safe-area-inset-bottom))`
31 | },
32 | [`.${e(`pl-${modifier}-safe`)}`]: {
33 | "padding-left": `max(${size}, env(safe-area-inset-left))`
34 | }
35 | }))
36 |
37 | addUtilities(utilities, variants)
38 | })
39 |
40 | module.exports = {
41 | content: ["./src/pages/**/*.tsx", "./src/components/**/*.tsx"],
42 | darkMode: "class",
43 | theme: {
44 | extend: {
45 | boxShadow: {
46 | slider:
47 | "0px 0px 1px rgba(0, 0, 0, 0.08), 0px 1px 4px rgba(0, 0, 0, 0.08), 0px 4px 12px rgba(0, 0, 0, 0.06), 0px 1px 1px rgba(0, 0, 0, 0.04)"
48 | },
49 | colors: {
50 | primary: {
51 | 50: "#ecfeff",
52 | 100: "#cffafe",
53 | 200: "#a5f3fc",
54 | 300: "#67e8f9",
55 | 400: "#22d3ee",
56 | 500: "#06b6d4",
57 | 600: "#0891b2",
58 | 700: "#0e7490",
59 | 800: "#155e75",
60 | 900: "#164e63"
61 | },
62 | zinc: {
63 | 150: "#ececee",
64 | 250: "#dcdce0",
65 | 350: "#bbbbc1",
66 | 450: "#898992",
67 | 550: "#62626b",
68 | 650: "#494951",
69 | 750: "#333338",
70 | 850: "#202023",
71 | 950: "#121215"
72 | }
73 | },
74 | fontFamily: {
75 | cursive: "cursive",
76 | sans: ["Inter", ...defaultTheme.fontFamily.sans]
77 | },
78 | fontSize: {
79 | "2xs": [
80 | "0.65rem",
81 | {
82 | lineHeight: 1
83 | }
84 | ]
85 | },
86 | typography: {
87 | DEFAULT: {
88 | css: {
89 | maxWidth: null,
90 | pre: {
91 | color: null,
92 | backgroundColor: null
93 | },
94 | "code::before": null,
95 | "code::after": null,
96 | ol: {
97 | paddingLeft: "1.25em"
98 | },
99 | ul: {
100 | listStyleType: "none",
101 | paddingLeft: "1.25em"
102 | },
103 | li: {
104 | position: "relative"
105 | },
106 | "ul > li::marker": null,
107 | "ul > li::before": {
108 | content: `""`,
109 | position: "absolute",
110 | backgroundColor: "var(--tw-prose-bullets)",
111 | borderRadius: "100px",
112 | width: "0.75em",
113 | height: "0.125em",
114 | top: "0.8125em",
115 | left: "-1.25em"
116 | }
117 | }
118 | }
119 | },
120 | transitionProperty: {
121 | DEFAULT:
122 | "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, text-decoration-color"
123 | },
124 | zIndex: {
125 | negative: -1
126 | }
127 | }
128 | },
129 | plugins: [require("@tailwindcss/typography"), paddingSafe]
130 | }
131 |
--------------------------------------------------------------------------------
/packages/site/src/components/controls/InstallButton.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx"
2 | import type { Transition, Variants } from "framer-motion"
3 | import { motion, wrap } from "framer-motion"
4 | import type { ComponentProps } from "react"
5 | import { useCallback, useRef, useState } from "react"
6 | import { ScrambledText } from "../miscellaneous/ScrambledText"
7 |
8 | const INSTALL_COMMANDS = ["npm i", "yarn add", "pnpm i"]
9 | const ANIMATION_DELAY = 3000
10 |
11 | const transition: Transition = {
12 | default: {
13 | type: "spring",
14 | stiffness: 300,
15 | damping: 20,
16 | delay: 0.1
17 | },
18 | opacity: {
19 | type: "spring",
20 | stiffness: 300,
21 | damping: 30
22 | }
23 | }
24 |
25 | const variants: Variants = {
26 | hidden: {
27 | pathLength: 0,
28 | opacity: 0
29 | },
30 | visible: {
31 | pathLength: 1,
32 | opacity: 1
33 | }
34 | }
35 |
36 | function useInstallCommand() {
37 | const index = useRef(0)
38 | const [command, setCommand] = useState(INSTALL_COMMANDS[index.current])
39 | const [previousCommand, setPreviousCommand] = useState()
40 |
41 | const cycleCommand = useCallback(() => {
42 | setPreviousCommand(INSTALL_COMMANDS[index.current])
43 |
44 | index.current = wrap(0, INSTALL_COMMANDS.length, index.current + 1)
45 |
46 | setCommand(INSTALL_COMMANDS[index.current])
47 | }, [])
48 |
49 | return [command, previousCommand, cycleCommand] as const
50 | }
51 |
52 | /**
53 | * A `button` which copies install commands to the clipboard.
54 | *
55 | * @param props - A set of `button` props.
56 | * @param [props.className] - A list of one or more classes.
57 | */
58 | export function InstallButton({
59 | className,
60 | ...props
61 | }: ComponentProps<"button">) {
62 | const timeout = useRef(0)
63 | const [command, previousCommand, cycleCommand] = useInstallCommand()
64 | const [isCopied, setCopied] = useState(false)
65 |
66 | const handleClick = useCallback(() => {
67 | window.clearTimeout(timeout.current)
68 |
69 | navigator.clipboard.writeText(`${command} typometer`).then(() => {
70 | setCopied(true)
71 |
72 | timeout.current = window.setTimeout(() => {
73 | setCopied(false)
74 | }, ANIMATION_DELAY)
75 | })
76 |
77 | cycleCommand()
78 | }, [command, cycleCommand])
79 |
80 | return (
81 |
134 | )
135 | }
136 |
--------------------------------------------------------------------------------
/packages/site/public/fonts/inter/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2016-2020 The Inter Project Authors.
2 | "Inter" is trademark of Rasmus Andersson.
3 | https://github.com/rsms/inter
4 |
5 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
6 | This license is copied below, and is also available with a FAQ at:
7 | http://scripts.sil.org/OFL
8 |
9 | ---
10 |
11 | ## SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
12 |
13 | PREAMBLE
14 | The goals of the Open Font License (OFL) are to stimulate worldwide
15 | development of collaborative font projects, to support the font creation
16 | efforts of academic and linguistic communities, and to provide a free and
17 | open framework in which fonts may be shared and improved in partnership
18 | with others.
19 |
20 | The OFL allows the licensed fonts to be used, studied, modified and
21 | redistributed freely as long as they are not sold by themselves. The
22 | fonts, including any derivative works, can be bundled, embedded,
23 | redistributed and/or sold with any software provided that any reserved
24 | names are not used by derivative works. The fonts and derivatives,
25 | however, cannot be released under any other type of license. The
26 | requirement for fonts to remain under this license does not apply
27 | to any document created using the fonts or their derivatives.
28 |
29 | DEFINITIONS
30 | "Font Software" refers to the set of files released by the Copyright
31 | Holder(s) under this license and clearly marked as such. This may
32 | include source files, build scripts and documentation.
33 |
34 | "Reserved Font Name" refers to any names specified as such after the
35 | copyright statement(s).
36 |
37 | "Original Version" refers to the collection of Font Software components as
38 | distributed by the Copyright Holder(s).
39 |
40 | "Modified Version" refers to any derivative made by adding to, deleting,
41 | or substituting -- in part or in whole -- any of the components of the
42 | Original Version, by changing formats or by porting the Font Software to a
43 | new environment.
44 |
45 | "Author" refers to any designer, engineer, programmer, technical
46 | writer or other person who contributed to the Font Software.
47 |
48 | PERMISSION AND CONDITIONS
49 | Permission is hereby granted, free of charge, to any person obtaining
50 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
51 | redistribute, and sell modified and unmodified copies of the Font
52 | Software, subject to the following conditions:
53 |
54 | 1. Neither the Font Software nor any of its individual components,
55 | in Original or Modified Versions, may be sold by itself.
56 |
57 | 2. Original or Modified Versions of the Font Software may be bundled,
58 | redistributed and/or sold with any software, provided that each copy
59 | contains the above copyright notice and this license. These can be
60 | included either as stand-alone text files, human-readable headers or
61 | in the appropriate machine-readable metadata fields within text or
62 | binary files as long as those fields can be easily viewed by the user.
63 |
64 | 3. No Modified Version of the Font Software may use the Reserved Font
65 | Name(s) unless explicit written permission is granted by the corresponding
66 | Copyright Holder. This restriction only applies to the primary font name as
67 | presented to the users.
68 |
69 | 4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
70 | Software shall not be used to promote, endorse or advertise any
71 | Modified Version, except to acknowledge the contribution(s) of the
72 | Copyright Holder(s) and the Author(s) or with their explicit written
73 | permission.
74 |
75 | 5. The Font Software, modified or unmodified, in part or in whole,
76 | must be distributed entirely under this license, and must not be
77 | distributed under any other license. The requirement for fonts to
78 | remain under this license does not apply to any document created
79 | using the Font Software.
80 |
81 | TERMINATION
82 | This license becomes null and void if any of the above conditions are
83 | not met.
84 |
85 | DISCLAIMER
86 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
87 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
88 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
89 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
90 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
91 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
92 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
93 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
94 | OTHER DEALINGS IN THE FONT SOFTWARE.
95 |
--------------------------------------------------------------------------------
/packages/site/src/components/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx"
2 | import type { Transition, Variants } from "framer-motion"
3 | import { motion } from "framer-motion"
4 | import Image from "next/image"
5 | import type { ComponentProps } from "react"
6 | import portrait from "../../../public/portrait.jpg"
7 | import { useData } from "../../hooks/use-data"
8 | import { useSystemTheme } from "../../hooks/use-system-theme"
9 |
10 | const THEME_TRANSITION_DURATION = 0.6
11 |
12 | const themeTransition: Transition = {
13 | default: {
14 | type: "spring",
15 | duration: THEME_TRANSITION_DURATION,
16 | bounce: 0.6
17 | },
18 | opacity: {
19 | type: "spring",
20 | bounce: 0,
21 | duration: THEME_TRANSITION_DURATION / 2
22 | }
23 | }
24 |
25 | const themeVariants: Variants = {
26 | hidden: {
27 | scale: 0.8,
28 | opacity: 0
29 | },
30 | visible: {
31 | scale: 1,
32 | opacity: 1,
33 | transition: {
34 | ...themeTransition,
35 | delay: THEME_TRANSITION_DURATION / 2
36 | }
37 | }
38 | }
39 |
40 | /**
41 | * A header section.
42 | *
43 | * @param props - A set of `header` props.
44 | * @param [props.className] - A list of one or more classes.
45 | */
46 | export function Header({ className, ...props }: ComponentProps<"header">) {
47 | const [theme, toggleTheme] = useSystemTheme()
48 | const { version } = useData()
49 |
50 | return (
51 |
52 |
53 |
54 |
59 |
66 | {" "}
67 |
82 | Typometer
83 |
87 | v{version}
88 |
89 |
90 |
91 |
129 |
130 |
131 |
132 | )
133 | }
134 |
--------------------------------------------------------------------------------
/packages/typometer/README.md:
--------------------------------------------------------------------------------
1 | #
2 |
3 | 🖊️ Measure text asynchronously.
4 |
5 | [](https://github.com/marcbouchenoire/typometer/actions/workflows/ci.yml)
6 | [](https://www.npmjs.com/package/typometer)
7 | [](https://bundlephobia.com/package/typometer)
8 | [](https://codecov.io/gh/marcbouchenoire/typometer)
9 | [](https://github.com/marcbouchenoire/typometer/blob/main/LICENSE)
10 |
11 | - 🗜️ **Small**: Just around **1 kB** on modern platforms
12 | - ⚡️ **Multi-threaded**: Run from a [Worker](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) when [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) is supported
13 | - 🧪 **Reliable**: Fully tested with [100% code coverage](https://codecov.io/gh/marcbouchenoire/typometer)
14 | - 📦 **Typed**: Written in [TypeScript](https://www.typescriptlang.org/) and includes definitions out-of-the-box
15 | - 💨 **Zero dependencies**
16 |
17 | ## Introduction
18 |
19 | Measuring text performantly in the browser isn't as straightforward as one would think—the recommended way is to leverage the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) (and its [`measureText`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/measureText) method) instead of relying on the DOM directly. Typometer embraces this way into a single function and attempts to smooth out the differences between the DOM and the Canvas API.
20 |
21 | When supported, Typometer will leverage an [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) from a [Worker](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) to measure in a background thread.
22 |
23 | #### Name
24 |
25 | Typometer is named after [a physical ruler](https://en.wikipedia.org/wiki/Typometer) used to measure in typographic points.
26 |
27 | ## Installation
28 |
29 | ```bash
30 | npm install typometer
31 | ```
32 |
33 | ## Usage
34 |
35 | Import `typometer`.
36 |
37 | ```typescript
38 | import { typometer } from "typometer"
39 | ```
40 |
41 | Invoke it asynchronously with a string and access serialized [`TextMetrics`](https://developer.mozilla.org/en-US/docs/Web/API/TextMetrics) in return.
42 |
43 | ```typescript
44 | const metrics = await typometer("With impressions chosen from another time.")
45 |
46 | // metrics: {
47 | // actualBoundingBoxAscent: 8,
48 | // actualBoundingBoxDescent: 3,
49 | // actualBoundingBoxLeft: 0,
50 | // actualBoundingBoxRight: 195.0732421875,
51 | // alphabeticBaseline: 0,
52 | // emHeightAscent: 10,
53 | // emHeightDescent: 2,
54 | // fontBoundingBoxAscent: 10,
55 | // fontBoundingBoxDescent: 2,
56 | // hangingBaseline: 10,
57 | // ideographicBaseline: -2,
58 | // width: 195.0732421875
59 | // }
60 | ```
61 |
62 | Given an array of strings instead, `typometer` will return an array of serialized [`TextMetrics`](https://developer.mozilla.org/en-US/docs/Web/API/TextMetrics).
63 |
64 | ```typescript
65 | const metrics = await typometer([
66 | "With impressions chosen from another time.",
67 | "Underneath a sky that's ever falling down."
68 | ])
69 |
70 | // metrics: [TextMetrics, TextMetrics]
71 | ```
72 |
73 | ## Options
74 |
75 | ### Font
76 |
77 | A secondary argument can be set to specify a font appearance—from [properties](#properties), a [`font`](#string) string, or a [`CSSStyleDeclaration`](#CSSStyleDeclaration).
78 |
79 | #### Properties
80 |
81 | Specify individual font properties as an object with [`fontFamily`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family), [`fontSize`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-size), [`fontStretch`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch), [`fontStyle`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style), [`fontVariant`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant), [`fontWeight`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight), and [`lineHeight`](https://developer.mozilla.org/en-US/docs/Web/CSS/line-height).
82 |
83 | ```typescript
84 | const metrics = await typometer("", {
85 | fontFamily: "cursive",
86 | fontSize: 16,
87 | fontStyle: "italic",
88 | fontWeight: 500,
89 | fontVariant: "small-caps",
90 | lineHeight: 2
91 | })
92 | ```
93 |
94 | ##### `string`
95 |
96 | Specify all font properties as a [`font`](https://developer.mozilla.org/en-US/docs/Web/CSS/font) shorthand string.
97 |
98 | ```typescript
99 | const metrics = await typometer("", "italic small-caps 500 16px/2 cursive")
100 | ```
101 |
102 | ##### `CSSStyleDeclaration`
103 |
104 | Specify a [`CSSStyleDeclaration`](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleDeclaration) from which to extract font properties.
105 |
106 | ```typescript
107 | const paragraph = document.querySelector("p")
108 | const metrics = await typometer("", window.getComputedStyle(paragraph))
109 | ```
110 |
--------------------------------------------------------------------------------
/packages/site/src/styles/main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* stylelint-disable selector-id-pattern */
6 | @layer base {
7 | html,
8 | body,
9 | #__next {
10 | @apply min-h-screen max-w-full overflow-x-hidden;
11 | }
12 |
13 | body {
14 | @apply selection:bg-primary-500/30 dark:selection:bg-primary-400/40 bg-white dark:bg-zinc-900;
15 |
16 | -webkit-tap-highlight-color: transparent;
17 | }
18 |
19 | #__next {
20 | @apply flex flex-col;
21 |
22 | > * {
23 | @apply w-full;
24 | }
25 | }
26 |
27 | a,
28 | option,
29 | label,
30 | *[role="button"],
31 | button:not([disabled]),
32 | input:not([disabled]),
33 | textarea:not([disabled]),
34 | select:not([disabled]) {
35 | @apply touch-manipulation;
36 | }
37 | }
38 | /* stylelint-enable selector-id-pattern */
39 |
40 | @layer utilities {
41 | .focusable {
42 | @apply focus-visible:ring-primary-500/40 dark:focus-visible:ring-primary-400/40 box-decoration-clone focus-visible:!decoration-transparent focus-visible:outline-none focus-visible:ring;
43 | }
44 |
45 | .highlight {
46 | &::before {
47 | @apply pointer-events-none absolute inset-0 z-10;
48 |
49 | border-radius: inherit;
50 | box-shadow: inset 0 0 0 1px rgb(0 0 0 / 6%);
51 | content: "";
52 | }
53 | }
54 |
55 | .highlight-invert {
56 | &::before {
57 | box-shadow: inset 0 0 0 1px rgb(255 255 255 / 8%);
58 | }
59 | }
60 |
61 | .link {
62 | @apply focusable rounded-sm underline decoration-zinc-600/20 decoration-2 underline-offset-2 transition selection:decoration-zinc-600/20 hover:decoration-zinc-600/40 focus-visible:decoration-transparent dark:decoration-white/20 dark:selection:decoration-white/20 dark:hover:decoration-white/40;
63 | }
64 |
65 | .link-primary {
66 | @apply decoration-primary-500/30 dark:decoration-primary-400/30 selection:decoration-primary-500/30 dark:selection:decoration-primary-400/30 hover:decoration-primary-500/60 dark:hover:decoration-primary-400/60;
67 | }
68 |
69 | .scrollbar {
70 | --scrollbar-color: theme("colors.zinc.150");
71 |
72 | scrollbar-color: var(--scrollbar-color) transparent;
73 | scrollbar-width: thin;
74 |
75 | &:hover {
76 | --scrollbar-color: theme("colors.zinc.200");
77 | }
78 |
79 | &::-webkit-scrollbar {
80 | @apply bg-transparent;
81 |
82 | width: 18px;
83 | height: 18px;
84 | }
85 |
86 | &::-webkit-scrollbar-thumb {
87 | @apply rounded-full;
88 |
89 | border: solid 6px transparent;
90 | box-shadow: inset 0 0 0 20px var(--scrollbar-color);
91 | }
92 |
93 | .dark & {
94 | --scrollbar-color: theme("colors.zinc.750");
95 |
96 | &:hover {
97 | --scrollbar-color: theme("colors.zinc.700");
98 | }
99 | }
100 | }
101 | }
102 |
103 | @layer components {
104 | .content {
105 | @apply px-5-safe mx-auto max-w-screen-sm;
106 | }
107 |
108 | .content-lg {
109 | @apply px-5-safe mx-auto max-w-screen-lg;
110 | }
111 | }
112 |
113 | :not(pre) > code {
114 | @apply relative whitespace-nowrap;
115 |
116 | margin: 0 0.4em;
117 |
118 | &::before {
119 | @apply z-negative dark:bg-zinc-750 absolute bg-zinc-100;
120 |
121 | border-radius: 0.4em;
122 | content: "";
123 | inset: -0.25em -0.4em -0.3em;
124 | }
125 | }
126 |
127 | pre[class*="language-"] {
128 | .token {
129 | &.important,
130 | &.bold {
131 | @apply font-bold;
132 | }
133 |
134 | &.entity {
135 | @apply cursor-help;
136 | }
137 |
138 | &.comment,
139 | &.prolog,
140 | &.cdata,
141 | .language-markdown &.blockquote.punctuation,
142 | .language-markdown &.hr.punctuation {
143 | @apply text-zinc-400 dark:text-zinc-500;
144 | }
145 |
146 | &.comment,
147 | .language-markdown &.blockquote.punctuation,
148 | .language-markdown &.hr.punctuation {
149 | @apply italic;
150 | }
151 |
152 | &.doctype,
153 | &.punctuation,
154 | &.entity,
155 | &.attr-value > &.punctuation.attr-equals,
156 | &.special-attr > &.attr-value > &.value.css,
157 | .language-css &.property,
158 | .language-json &.operator,
159 | .language-markdown &.url,
160 | .language-markdown &.url > &.operator,
161 | .language-markdown &.url-reference.url > &.string {
162 | @apply text-zinc-500 dark:text-zinc-400;
163 | }
164 |
165 | &.keyword,
166 | .language-css &.important,
167 | .language-css &.atrule &.rule,
168 | .language-javascript &.operator,
169 | .language-jsx &.operator,
170 | .language-typescript &.operator,
171 | .language-tsx &.operator {
172 | @apply text-yellow-600 dark:text-yellow-300;
173 | }
174 |
175 | &.property,
176 | &.tag,
177 | &.symbol,
178 | &.deleted,
179 | &.important,
180 | .language-css &.selector,
181 | .language-markdown &.strike &.content,
182 | .language-markdown &.strike &.punctuation,
183 | .language-markdown &.list.punctuation,
184 | .language-markdown &.title.important > &.punctuation {
185 | @apply text-cyan-600 dark:text-cyan-300;
186 | }
187 |
188 | &.attr-name,
189 | &.class-name,
190 | &.boolean,
191 | &.constant,
192 | &.number,
193 | &.atrule,
194 | .language-json &.null.keyword,
195 | .language-markdown &.bold &.content {
196 | @apply bg-violet-600/5 text-violet-600 dark:bg-violet-400/10 dark:text-violet-400;
197 |
198 | padding: 0 0.2em;
199 | border-radius: 0.2em;
200 | }
201 |
202 | &.selector,
203 | &.char,
204 | &.builtin,
205 | &.inserted,
206 | &.regex,
207 | &.string,
208 | &.attr-value,
209 | &.attr-value > &.punctuation,
210 | .language-css &.url > &.string.url,
211 | .language-markdown &.code-snippet {
212 | @apply bg-lime-600/5 text-lime-600 dark:bg-lime-300/10 dark:text-lime-300;
213 |
214 | padding: 0 0.2em;
215 | border-radius: 0.2em;
216 | }
217 |
218 | &.variable,
219 | &.operator,
220 | &.function,
221 | .language-markdown &.url > &.content {
222 | @apply text-sky-600 dark:text-sky-300;
223 | }
224 |
225 | &.url,
226 | .language-css &.function,
227 | .language-css &.url > &.function,
228 | .language-markdown &.url > &.url,
229 | .language-markdown &.url-reference.url {
230 | @apply text-pink-600 dark:text-pink-300;
231 | }
232 | }
233 | }
234 |
235 | .prose {
236 | h1,
237 | h2,
238 | h3,
239 | h4,
240 | h5,
241 | h6 {
242 | @apply scroll-mt-6;
243 |
244 | a[aria-hidden="true"] {
245 | @apply relative hidden text-zinc-300 no-underline opacity-0 transition hover:!text-zinc-400 focus-visible:outline-none dark:text-zinc-600 dark:hover:!text-zinc-500 md:block;
246 |
247 | padding-right: 1em;
248 | margin-left: -1em;
249 |
250 | &::before {
251 | @apply absolute;
252 |
253 | content: "#";
254 | }
255 | }
256 |
257 | &:hover a[aria-hidden="true"] {
258 | @apply opacity-100;
259 | }
260 | }
261 |
262 | pre[class*="language-"] {
263 | @apply scrollbar border-zinc-150 rounded-lg border dark:border-zinc-800;
264 | }
265 |
266 | a:not([aria-hidden="true"]) {
267 | @apply link;
268 | }
269 |
270 | details {
271 | @apply my-1;
272 |
273 | summary {
274 | @apply focusable my-1 cursor-pointer rounded ring-offset-2 transition dark:ring-offset-zinc-900;
275 | }
276 | }
277 | }
278 |
279 | .prose-primary {
280 | a:not([aria-hidden="true"]) {
281 | @apply link-primary;
282 | }
283 | }
284 |
285 | .portrait {
286 | @apply highlight relative inline-block overflow-hidden rounded-full bg-white ring-offset-2;
287 |
288 | box-shadow: 0 0 0 2px theme("colors.white"), 0 0 1px 2px rgb(0 0 0 / 8%),
289 | 0 1px 4px 2px rgb(0 0 0 / 8%), 0 2px 8px 2px rgb(0 0 0 / 6%);
290 |
291 | & > span {
292 | @apply !absolute !inset-0 !h-full !w-full;
293 | }
294 | }
295 |
296 | .aura {
297 | background: radial-gradient(
298 | farthest-side at center -100%,
299 | theme("colors.primary.500"),
300 | transparent
301 | );
302 | }
303 |
304 | .logo {
305 | padding-top: 0.2em;
306 |
307 | img {
308 | @apply h-auto max-w-full;
309 |
310 | width: calc(1.2em * 415 / 80);
311 | margin-bottom: -0.2em;
312 | }
313 | }
314 |
--------------------------------------------------------------------------------
/packages/site/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/site/src/components/sections/Editor.tsx:
--------------------------------------------------------------------------------
1 | import { Root as Label } from "@radix-ui/react-label"
2 | import { clsx } from "clsx"
3 | import type { Transition } from "framer-motion"
4 | import { motion } from "framer-motion"
5 | import debounce from "just-debounce-it"
6 | import type { ChangeEvent, ComponentProps, ReactNode } from "react"
7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"
8 | import type { Font, SerializedTextMetrics } from "typometer"
9 | import { typometer } from "typometer"
10 | import { useKey } from "../../hooks/use-key"
11 | import { SegmentedControl } from "../controls/SegmentedControl"
12 | import { Select } from "../controls/Select"
13 | import { Slider } from "../controls/Slider"
14 |
15 | interface MetricsProps extends ComponentProps<"pre"> {
16 | /**
17 | * The serialized `TextMetrics` object to display.
18 | */
19 | metrics?: SerializedTextMetrics
20 | }
21 |
22 | interface Properties {
23 | /**
24 | * Select a generic font family.
25 | */
26 | family: "cursive" | "monospace" | "sans-serif" | "serif"
27 |
28 | /**
29 | * Set the size of the font.
30 | */
31 | size: number
32 |
33 | /**
34 | * Select a normal or italic face from the font.
35 | */
36 | style: "italic" | "normal"
37 |
38 | /**
39 | * Set the weight of the font.
40 | */
41 | weight: number
42 | }
43 |
44 | interface Weight {
45 | /**
46 | * The weight's common name.
47 | */
48 | name: string
49 |
50 | /**
51 | * The weight's numbered value.
52 | */
53 | value: number
54 | }
55 |
56 | const weights: Weight[] = [
57 | { name: "Ultralight", value: 100 },
58 | { name: "Thin", value: 200 },
59 | { name: "Light", value: 300 },
60 | { name: "Regular", value: 400 },
61 | { name: "Medium", value: 500 },
62 | { name: "Semibold", value: 600 },
63 | { name: "Bold", value: 700 },
64 | { name: "Heavy", value: 800 },
65 | { name: "Black", value: 900 }
66 | ]
67 |
68 | const weightOptions = weights.map((weight) => (
69 |
72 | ))
73 |
74 | const defaultMetrics: SerializedTextMetrics = {
75 | actualBoundingBoxAscent: 0,
76 | actualBoundingBoxDescent: 0,
77 | actualBoundingBoxLeft: 0,
78 | actualBoundingBoxRight: 0,
79 | fontBoundingBoxAscent: 0,
80 | fontBoundingBoxDescent: 0,
81 | width: 0
82 | }
83 |
84 | const arrowTransition: Transition = {
85 | type: "spring",
86 | stiffness: 400,
87 | damping: 60
88 | }
89 |
90 | /**
91 | * Display an highlighted `TextMetrics` object.
92 | *
93 | * @param props - A set of `pre` props.
94 | * @param props.metrics - The serialized `TextMetrics` object to display.
95 | * @param [props.className] - A list of one or more classes.
96 | */
97 | function Metrics({ metrics, className, ...props }: MetricsProps) {
98 | const entries = useMemo(() => {
99 | const entries = metrics ? Object.entries(metrics) : []
100 |
101 | return entries.sort(([a], [b]) => a.localeCompare(b))
102 | }, [metrics])
103 |
104 | return (
105 |
112 |
113 |
114 | {"{"}
115 | {"\n"}
116 |
117 | {entries.map(([property, value], index) => {
118 | const isLast = index === entries.length - 1
119 |
120 | return (
121 |
122 | {" "}
123 | "{property}"
124 | :{" "}
125 | {value}
126 | {!isLast && ,}
127 | {"\n"}
128 |
129 | )
130 | })}
131 |
132 | {"}"}
133 |
134 |
135 |
136 | )
137 | }
138 |
139 | /**
140 | * An interactive section to explore text metrics.
141 | *
142 | * @param props - A set of `section` props.
143 | */
144 | export function Editor(props: ComponentProps<"section">) {
145 | const inputRef = useRef(null)
146 | const [key, setKey] = useState(0)
147 | const [value, setValue] = useState("")
148 | const [duration, setDuration] = useState("0")
149 | const [metrics, setMetrics] = useState(defaultMetrics)
150 | const [{ family, size, weight, style }, setProperties] = useState(
151 | () => ({
152 | family: "sans-serif",
153 | size: 16,
154 | weight: 400,
155 | style: "normal"
156 | })
157 | )
158 |
159 | const handleShortcutKey = useCallback((event: KeyboardEvent) => {
160 | if (document.activeElement !== inputRef?.current) {
161 | event?.preventDefault()
162 | }
163 |
164 | inputRef?.current?.focus()
165 | }, [])
166 |
167 | const handleMeasureClick = useCallback(() => {
168 | setKey((key) => key + 1)
169 | }, [])
170 |
171 | const handleValueChange = useCallback(
172 | (event: ChangeEvent) => {
173 | setValue(event.target.value)
174 | },
175 | []
176 | )
177 |
178 | const handleFamilyChange = useCallback((value: string) => {
179 | if (value) {
180 | setProperties((properties) => ({
181 | ...properties,
182 | family: value as Properties["family"]
183 | }))
184 | }
185 | }, [])
186 |
187 | const handleSizeChange = useCallback((value: number) => {
188 | setProperties((properties) => ({
189 | ...properties,
190 | size: value
191 | }))
192 | }, [])
193 |
194 | const handleWeightChange = useCallback(
195 | (event: ChangeEvent) => {
196 | setProperties((properties) => ({
197 | ...properties,
198 | weight: Number(event.target.value)
199 | }))
200 | },
201 | []
202 | )
203 |
204 | const handleStyleChange = useCallback((value: string) => {
205 | if (value) {
206 | setProperties((properties) => ({
207 | ...properties,
208 | style: value as Properties["style"]
209 | }))
210 | }
211 | }, [])
212 |
213 | // eslint-disable-next-line react-hooks/exhaustive-deps
214 | const measure = useCallback(
215 | debounce((value: string, font: Font) => {
216 | const date = performance.now()
217 |
218 | typometer(value, font).then((metrics) => {
219 | const duration = performance.now() - date
220 | const formattedDuration =
221 | duration <= 1 ? (
222 | <>
223 |
224 | ≤
225 | {" "}
226 | 1
227 | >
228 | ) : (
229 | <>
230 |
231 | ≈
232 | {" "}
233 | {Math.round(duration)}
234 | >
235 | )
236 |
237 | setDuration(formattedDuration)
238 | setMetrics(metrics)
239 | })
240 | }, 100),
241 | []
242 | )
243 |
244 | useEffect(() => {
245 | measure(value, {
246 | fontFamily: family,
247 | fontSize: size,
248 | fontWeight: weight,
249 | fontStyle: style
250 | })
251 | }, [measure, key, value, family, size, weight, style])
252 |
253 | useKey("/", handleShortcutKey)
254 |
255 | return (
256 |
257 |
258 |
259 |
260 |
268 |
269 | /
270 |
271 |
272 |
273 |
298 |
299 |
300 |
301 |
302 |
322 |
343 |
376 |
401 |
402 |
403 |
404 | )
405 | }
406 |
--------------------------------------------------------------------------------