├── README.md ├── packages ├── site │ ├── .eslintignore │ ├── .stylelintrc.json │ ├── public │ │ ├── meta.png │ │ ├── favicon.png │ │ ├── portrait.jpg │ │ ├── fonts │ │ │ └── inter │ │ │ │ ├── inter.woff2 │ │ │ │ └── LICENSE │ │ ├── favicon.svg │ │ └── logo.svg │ ├── declarations.d.ts │ ├── src │ │ ├── scripts │ │ │ └── postinstall.mjs │ │ ├── types.ts │ │ ├── hooks │ │ │ ├── use-data.ts │ │ │ ├── use-key.ts │ │ │ ├── use-interval.ts │ │ │ └── use-system-theme.ts │ │ ├── utils │ │ │ └── random.ts │ │ ├── plugins │ │ │ ├── rehype │ │ │ │ └── remove-images.ts │ │ │ └── remark │ │ │ │ ├── find-node.ts │ │ │ │ └── filter-headings.ts │ │ ├── styles │ │ │ ├── fonts.css │ │ │ └── main.css │ │ ├── guards.ts │ │ ├── pages │ │ │ ├── _document.tsx │ │ │ ├── 404.tsx │ │ │ ├── index.tsx │ │ │ └── _app.tsx │ │ └── components │ │ │ ├── controls │ │ │ ├── Select.tsx │ │ │ ├── Slider.tsx │ │ │ ├── SegmentedControl.tsx │ │ │ └── InstallButton.tsx │ │ │ ├── layout │ │ │ ├── Footer.tsx │ │ │ └── Header.tsx │ │ │ ├── sections │ │ │ ├── Introduction.tsx │ │ │ └── Editor.tsx │ │ │ └── miscellaneous │ │ │ └── ScrambledText.tsx │ ├── .postcssrc.json │ ├── next-env.d.ts │ ├── .eslintrc.json │ ├── tsconfig.json │ ├── next.config.mjs │ ├── package.json │ └── tailwind.config.cjs └── typometer │ ├── .eslintignore │ ├── .eslintrc.json │ ├── tsconfig.build.json │ ├── src │ ├── utils │ │ ├── is-browser.ts │ │ ├── normalize-string.ts │ │ ├── supports-canvas.ts │ │ ├── send-message.ts │ │ ├── serialize-text-metrics.ts │ │ ├── get-font.ts │ │ └── get-font-properties.ts │ ├── measure-text.worker.ts │ ├── types.ts │ ├── index.ts │ ├── guards.ts │ └── measure-text.ts │ ├── tsconfig.json │ ├── tests │ ├── utils │ │ ├── normalize-string.test.ts │ │ ├── serialize-text-metrics.test.ts │ │ ├── send-message.test.ts │ │ ├── get-font.test.ts │ │ └── get-font-properties.test.ts │ ├── constants.ts │ ├── helpers.ts │ ├── index.test.ts │ └── guards.test.ts │ ├── web-test-runner.config.mjs │ ├── rollup.config.mjs │ ├── package.json │ ├── rollup │ └── worker.mjs │ └── README.md ├── pnpm-workspace.yaml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── .changeset └── config.json ├── turbo.json ├── tsconfig.json ├── .github └── workflows │ ├── ci.yml │ └── version.yml ├── package.json └── LICENSE /README.md: -------------------------------------------------------------------------------- 1 | ./packages/typometer/README.md -------------------------------------------------------------------------------- /packages/site/.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .next 3 | *.log -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/**" 3 | -------------------------------------------------------------------------------- /packages/typometer/.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | dist 5 | *.log -------------------------------------------------------------------------------- /packages/site/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@marcbouchenoire/stylelint-config" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | dist 5 | .next 6 | .turbo 7 | data.json 8 | *.tsbuildinfo 9 | *.log -------------------------------------------------------------------------------- /packages/site/public/meta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcbouchenoire/typometer/HEAD/packages/site/public/meta.png -------------------------------------------------------------------------------- /packages/site/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*/data.json" { 2 | const value: any 3 | 4 | export default value 5 | } 6 | -------------------------------------------------------------------------------- /packages/site/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcbouchenoire/typometer/HEAD/packages/site/public/favicon.png -------------------------------------------------------------------------------- /packages/site/public/portrait.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcbouchenoire/typometer/HEAD/packages/site/public/portrait.jpg -------------------------------------------------------------------------------- /packages/site/src/scripts/postinstall.mjs: -------------------------------------------------------------------------------- 1 | import { storeStaticFiles } from "../../next.config.mjs" 2 | 3 | storeStaticFiles() 4 | -------------------------------------------------------------------------------- /packages/site/public/fonts/inter/inter.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcbouchenoire/typometer/HEAD/packages/site/public/fonts/inter/inter.woff2 -------------------------------------------------------------------------------- /packages/site/.postcssrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "autoprefixer": {}, 4 | "tailwindcss/nesting": {}, 5 | "tailwindcss": {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/typometer/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@marcbouchenoire/eslint-config", 4 | "@marcbouchenoire/eslint-config/jsdoc" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /packages/typometer/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "rootDir": "src" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "stylelint.vscode-stylelint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/typometer/src/utils/is-browser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Whether the current environment is a browser. 3 | */ 4 | export function isBrowser() { 5 | return typeof window !== "undefined" 6 | } 7 | -------------------------------------------------------------------------------- /packages/typometer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "include": ["src", "tests"], 4 | "compilerOptions": { 5 | "lib": ["dom", "webworker", "esnext"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/site/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Data { 2 | /** 3 | * The current year. 4 | */ 5 | date: string 6 | 7 | /** 8 | * The latest package version. 9 | */ 10 | version: string 11 | } 12 | -------------------------------------------------------------------------------- /packages/site/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "baseBranch": "main", 5 | "ignore": ["site"], 6 | "access": "public", 7 | "commit": false 8 | } 9 | -------------------------------------------------------------------------------- /packages/site/src/hooks/use-data.ts: -------------------------------------------------------------------------------- 1 | import data from "../data.json" 2 | import type { Data } from "../types" 3 | 4 | /** 5 | * Fetch a static data object. 6 | */ 7 | export function useData(): Partial { 8 | return (data as Data) ?? {} 9 | } 10 | -------------------------------------------------------------------------------- /packages/typometer/src/utils/normalize-string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove line breaks from a string. 3 | * 4 | * @param string - The string to normalize. 5 | */ 6 | export function normalizeString(string: string) { 7 | return string.replace(/\r?\n|\r/gm, "").trim() 8 | } 9 | -------------------------------------------------------------------------------- /packages/site/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@marcbouchenoire/eslint-config", 4 | "@marcbouchenoire/eslint-config/react", 5 | "@marcbouchenoire/eslint-config/jsdoc" 6 | ], 7 | "rules": { 8 | "import/no-unresolved": ["error", { "ignore": ["mdast", "unist"] }] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "include": ["next-env.d.ts", "declarations.d.ts", "src"], 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "resolveJsonModule": true, 7 | "isolatedModules": true, 8 | "jsx": "preserve", 9 | "incremental": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/site/src/utils/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate a random integer within a given range. 3 | * 4 | * @param [minimum] - The minimum value. 5 | * @param [maximum] - The maximum value. 6 | */ 7 | export function random(minimum = 0, maximum = 1) { 8 | return Math.floor(Math.random() * (maximum - minimum + 1) + minimum) 9 | } 10 | -------------------------------------------------------------------------------- /packages/typometer/src/utils/supports-canvas.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Whether `HTMLCanvasElement` exists. 3 | */ 4 | export function supportsCanvas() { 5 | return typeof HTMLCanvasElement !== "undefined" 6 | } 7 | 8 | /** 9 | * Whether `OffscreenCanvas` exists. 10 | */ 11 | export function supportsOffscreenCanvas() { 12 | return typeof OffscreenCanvas !== "undefined" 13 | } 14 | -------------------------------------------------------------------------------- /packages/site/src/plugins/rehype/remove-images.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from "unified" 2 | import { remove } from "unist-util-remove" 3 | 4 | /** 5 | * A plugin to remove all image nodes. 6 | */ 7 | const removeImages: Plugin = () => { 8 | return (tree) => { 9 | remove(tree, { tagName: "img", type: "element" }) 10 | } 11 | } 12 | 13 | export default removeImages 14 | -------------------------------------------------------------------------------- /packages/site/src/styles/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-display: optional; 3 | font-family: Inter; 4 | font-style: normal; 5 | font-weight: 100 900; 6 | src: url("/fonts/inter/inter.woff2") format("woff2"); 7 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 8 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 9 | U+FEFF, U+FFFD; 10 | } 11 | -------------------------------------------------------------------------------- /packages/site/src/guards.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Whether the value is not undefined or null. 3 | * 4 | * @param value - The value to check. 5 | */ 6 | export function isSomething(value: T): value is NonNullable { 7 | return value !== undefined && value !== null 8 | } 9 | 10 | /** 11 | * Whether the value is a number. 12 | * 13 | * @param value - The value to check. 14 | */ 15 | export function isNumber(value: number | unknown): value is number { 16 | return typeof value === "number" 17 | } 18 | -------------------------------------------------------------------------------- /packages/site/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/site/src/plugins/remark/find-node.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from "unified" 2 | import type { Parent } from "unist" 3 | import { select } from "unist-util-select" 4 | 5 | /** 6 | * A plugin to select a specific node. 7 | * 8 | * @param selector - The selector to match. 9 | */ 10 | const findNode: Plugin<[string], Parent> = (selector) => { 11 | return (tree) => { 12 | const node = select(selector, tree) 13 | tree.children = [] 14 | 15 | if (node) { 16 | tree.children.push(node) 17 | } 18 | } 19 | } 20 | 21 | export default findNode 22 | -------------------------------------------------------------------------------- /packages/typometer/tests/utils/normalize-string.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "uvu/assert" 2 | import { normalizeString } from "../../src/utils/normalize-string" 3 | 4 | describe("normalizeString", () => { 5 | const string = ` 6 | lorem 7 | ipsum 8 | ` 9 | 10 | it("shouldn't start or end with spaces", () => { 11 | assert.equal(normalizeString(string).startsWith(" "), false) 12 | assert.equal(normalizeString(string).endsWith(" "), false) 13 | }) 14 | 15 | it("shouldn't contain line breaks", () => { 16 | assert.not.match(normalizeString(string), /\r?\n|\r/) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**", ".next/**"] 7 | }, 8 | "dev": { 9 | "dependsOn": ["^build"], 10 | "cache": false 11 | }, 12 | "test": { 13 | "dependsOn": ["build"], 14 | "inputs": ["**/*.test.ts", "**/*.test.tsx"] 15 | }, 16 | "test:coverage": { 17 | "dependsOn": ["build"], 18 | "inputs": ["**/*.test.ts", "**/*.test.tsx"], 19 | "outputs": ["coverage/**"] 20 | }, 21 | "lint": {} 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.validate": false, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit", 5 | "source.fixAll.stylelint": "explicit" 6 | }, 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "editor.formatOnSave": true, 9 | "eslint.validate": ["typescript", "typescriptreact"], 10 | "eslint.workingDirectories": ["packages/site", "packages/typometer"], 11 | "files.exclude": { 12 | "node_modules": true 13 | }, 14 | "prettier.ignorePath": ".gitignore", 15 | "stylelint.configBasedir": "packages/site", 16 | "stylelint.enable": true 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "declaration": true, 6 | "downlevelIteration": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "lib": ["dom", "esnext"], 10 | "target": "esnext", 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "noEmit": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "resolveJsonModule": true, 17 | "skipLibCheck": true, 18 | "sourceMap": true, 19 | "strict": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/typometer/tests/constants.ts: -------------------------------------------------------------------------------- 1 | export const family = "sans-serif" 2 | export const size = 12 3 | export const stretch = "condensed" 4 | export const style = "italic" 5 | export const variant = "small-caps" 6 | export const weight = 500 7 | export const line = 2 8 | 9 | export const boolean = true as boolean 10 | export const number = 2 as number 11 | export const string = "lorem" as string 12 | 13 | export const array = [string, number] 14 | export const fun = () => {} 15 | export const map = new Map() 16 | export const object = { a: number, b: string } 17 | export const set = new Set() 18 | -------------------------------------------------------------------------------- /packages/typometer/src/measure-text.worker.ts: -------------------------------------------------------------------------------- 1 | import { serializeTextMetrics } from "./utils/serialize-text-metrics" 2 | 3 | export type WorkerMessage = [string, string | undefined] 4 | 5 | const canvas = new OffscreenCanvas(1, 1) 6 | const context = canvas.getContext("2d") as OffscreenCanvasRenderingContext2D 7 | const defaultFont = context.font 8 | 9 | addEventListener( 10 | "message", 11 | ({ data: [text, font], ports: [port] }: MessageEvent) => { 12 | context.font = font ?? defaultFont 13 | const metrics = context.measureText(text) 14 | 15 | port.postMessage(serializeTextMetrics(metrics)) 16 | }, 17 | false 18 | ) 19 | -------------------------------------------------------------------------------- /packages/typometer/src/utils/send-message.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Send and receive messages asynchronously with a `Worker`. 3 | * 4 | * @param worker - The `Worker` to exchange messages with. 5 | * @param message - The message to send. 6 | */ 7 | export function sendMessage(worker: Worker, message: M) { 8 | return new Promise((resolve) => { 9 | const { port1, port2 } = new MessageChannel() 10 | 11 | port1.addEventListener( 12 | "message", 13 | ({ data }: MessageEvent) => { 14 | port1.close() 15 | 16 | resolve(data) 17 | }, 18 | { 19 | once: true 20 | } 21 | ) 22 | 23 | port1.start() 24 | worker.postMessage(message, [port2]) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /packages/typometer/tests/utils/serialize-text-metrics.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "uvu/assert" 2 | import { serializeTextMetrics } from "../../src/utils/serialize-text-metrics" 3 | 4 | describe("serializeTextMetrics", () => { 5 | const canvas = document.createElement("canvas") 6 | const context = canvas.getContext("2d") 7 | const metrics = context?.measureText("") 8 | const serializedMetrics = serializeTextMetrics(metrics as TextMetrics) 9 | 10 | it("should convert a TextMetrics instance into a plain object", () => { 11 | assert.instance(serializedMetrics, Object) 12 | assert.not.instance(serializedMetrics, TextMetrics) 13 | assert.equal(serializedMetrics?.width, metrics?.width) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /packages/typometer/src/utils/serialize-text-metrics.ts: -------------------------------------------------------------------------------- 1 | import { isNumber } from "../guards" 2 | import type { SerializedTextMetrics } from "../types" 3 | 4 | /** 5 | * Serialize a `TextMetrics` object into a plain one. 6 | * 7 | * @param metrics - The `TextMetrics` object to serialize. 8 | */ 9 | export function serializeTextMetrics(metrics: TextMetrics) { 10 | const plainMetrics = {} as SerializedTextMetrics 11 | 12 | for (const property of Object.getOwnPropertyNames( 13 | Object.getPrototypeOf(metrics) 14 | ) as (keyof TextMetrics)[]) { 15 | const value = metrics[property] 16 | 17 | if (isNumber(value)) { 18 | plainMetrics[property] = value 19 | } 20 | } 21 | 22 | return plainMetrics 23 | } 24 | -------------------------------------------------------------------------------- /packages/typometer/web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { esbuildPlugin } from "@web/dev-server-esbuild" 2 | import { fromRollup } from "@web/dev-server-rollup" 3 | import { puppeteerLauncher } from "@web/test-runner-puppeteer" 4 | import { options } from "./rollup.config.mjs" 5 | import { worker } from "./rollup/worker.mjs" 6 | 7 | const rollupWorker = fromRollup(worker) 8 | 9 | export default { 10 | files: ["tests/**/*.test.ts"], 11 | nodeResolve: true, 12 | plugins: [ 13 | esbuildPlugin({ ts: true }), 14 | rollupWorker({ 15 | include: /measure-text\.ts/, 16 | options 17 | }) 18 | ], 19 | browsers: [puppeteerLauncher({ concurrency: 1 })], 20 | coverageConfig: { 21 | include: ["src/**/*.ts"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/site/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import NextDocument, { Head, Html, Main, NextScript } from "next/document" 2 | 3 | /** 4 | * A custom `Document` component. 5 | */ 6 | class Document extends NextDocument { 7 | /** 8 | * Describe the document markup. 9 | */ 10 | render() { 11 | return ( 12 | 13 | 14 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | ) 28 | } 29 | } 30 | 31 | export default Document 32 | -------------------------------------------------------------------------------- /packages/site/src/hooks/use-key.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from "react" 2 | 3 | /** 4 | * Invoke a function when pressing a specific key. 5 | * 6 | * @param key - The key to press. 7 | * @param callback - The function to invoke when pressing the key. 8 | */ 9 | export function useKey(key: string, callback: (event: KeyboardEvent) => void) { 10 | const handleKeyDown = useCallback( 11 | (event: KeyboardEvent) => { 12 | if (event.key === key) { 13 | callback(event) 14 | } 15 | }, 16 | [key, callback] 17 | ) 18 | 19 | useEffect(() => { 20 | window.addEventListener("keydown", handleKeyDown) 21 | 22 | return () => { 23 | window.removeEventListener("keydown", handleKeyDown) 24 | } 25 | }, [handleKeyDown]) 26 | } 27 | -------------------------------------------------------------------------------- /packages/typometer/tests/utils/send-message.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "uvu/assert" 2 | import { sendMessage } from "../../src/utils/send-message" 3 | import { number } from "../constants" 4 | 5 | describe("sendMessage", () => { 6 | const code = `addEventListener( 7 | "message", 8 | ({ data, ports: [port] }) => { 9 | port.postMessage(data * 2) 10 | }, 11 | false 12 | )` 13 | const worker = new Worker( 14 | URL.createObjectURL(new Blob([code], { type: "application/javascript" })) 15 | ) 16 | 17 | it("should send and receive corresponding messages asynchronously", async () => { 18 | assert.equal(await sendMessage(worker, number), number * 2) 19 | assert.equal(await sendMessage(worker, number * 2), number * 4) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /packages/typometer/src/utils/get-font.ts: -------------------------------------------------------------------------------- 1 | import { isCSSStyleDeclaration, isString } from "../guards" 2 | import type { Font, FontProperties } from "../types" 3 | import { getFontProperties } from "./get-font-properties" 4 | 5 | /** 6 | * Create a `font` string from properties, an existing `font` string, or a `CSSStyleDeclaration`. 7 | * 8 | * @param [font] - The properties, `font` string, or `CSSStyleDeclaration` to generate a `font` string from. 9 | */ 10 | export function getFont(font?: Font) { 11 | if (isCSSStyleDeclaration(font as CSSStyleDeclaration)) { 12 | return (font as CSSStyleDeclaration).getPropertyValue("font") 13 | } else if (isString(font)) { 14 | return font 15 | } else if (font) { 16 | return getFontProperties(font as FontProperties) 17 | } else { 18 | return undefined 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/site/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { createRequire } from "module" 2 | import path from "path" 3 | import { writeJsonFileSync } from "write-json-file" 4 | 5 | const DATA_PATH = path.resolve("./src/data.json") 6 | 7 | /** 8 | * Create a static data object. 9 | */ 10 | function getData() { 11 | const pkg = createRequire(import.meta.url)("../typometer/package.json") 12 | 13 | return { 14 | version: pkg.version, 15 | date: String(new Date().getFullYear()) 16 | } 17 | } 18 | 19 | /** 20 | * Store various things as static files. 21 | */ 22 | export function storeStaticFiles() { 23 | writeJsonFileSync(DATA_PATH, getData()) 24 | } 25 | 26 | export default () => { 27 | storeStaticFiles() 28 | 29 | return { 30 | trailingSlash: false, 31 | eslint: { 32 | ignoreDuringBuilds: true 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/site/src/hooks/use-interval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react" 2 | 3 | /** 4 | * Repeatedly call a function at a given time interval. 5 | * 6 | * @param callback - The function to invoke repeatedly. 7 | * @param interval - The interval in milliseconds. 8 | */ 9 | export function useInterval( 10 | callback: (...args: any[]) => void, 11 | interval: number | null 12 | ) { 13 | const latestCallback = useRef(callback) 14 | 15 | useEffect(() => { 16 | latestCallback.current = callback 17 | }, [callback]) 18 | 19 | useEffect(() => { 20 | if (interval !== null) { 21 | const intervalId = setInterval( 22 | (...args: any[]) => latestCallback.current(...args), 23 | interval 24 | ) 25 | 26 | return () => clearInterval(intervalId) 27 | } 28 | 29 | return 30 | }, [interval]) 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | concurrency: 10 | group: ${{ github.ref }} 11 | cancel-in-progress: true 12 | jobs: 13 | main: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | steps: 17 | - name: Checkout commit 18 | uses: actions/checkout@v3 19 | - uses: pnpm/action-setup@v2 20 | with: 21 | version: 7 22 | - name: Prepare Node.js 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 16 26 | cache: "pnpm" 27 | - name: Install dependencies 28 | run: pnpm install --frozen-lockfile 29 | - name: Build project 30 | run: pnpm build 31 | - name: Lint files 32 | run: pnpm lint 33 | - name: Run tests 34 | run: pnpm test:coverage 35 | - name: Upload coverage report 36 | uses: codecov/codecov-action@v2 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typometer", 3 | "private": true, 4 | "prettier": "@marcbouchenoire/prettier-config", 5 | "scripts": { 6 | "prettier": "prettier --write --loglevel silent --ignore-path .gitignore", 7 | "dev": "turbo dev", 8 | "build": "turbo build", 9 | "test": "turbo test", 10 | "test:coverage": "turbo test:coverage", 11 | "lint": "pnpm prettier '*.{json,md,yml}' && turbo lint", 12 | "change": "pnpm changeset", 13 | "version": "pnpm changeset version && pnpm install --no-frozen-lockfile", 14 | "release": "turbo build --filter=typometer && pnpm changeset publish" 15 | }, 16 | "devDependencies": { 17 | "@changesets/cli": "^2.26.0", 18 | "@marcbouchenoire/eslint-config": "^2.8.1", 19 | "@marcbouchenoire/prettier-config": "^1.1.0", 20 | "@types/node": "^18.11.18", 21 | "eslint": "^8.31.0", 22 | "lerna": "^6.4.0", 23 | "prettier": "^2.8.2", 24 | "tsatsiki": "^2.0.1", 25 | "turbo": "^1.7.4", 26 | "typescript": "^4.9.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/version.yml: -------------------------------------------------------------------------------- 1 | name: Update and/or publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | concurrency: ${{ github.workflow }}-${{ github.ref }} 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 10 11 | steps: 12 | - name: Checkout commit 13 | uses: actions/checkout@v3 14 | - uses: pnpm/action-setup@v2 15 | with: 16 | version: 7 17 | - name: Prepare Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 16 21 | cache: "pnpm" 22 | - name: Install dependencies 23 | run: pnpm install --frozen-lockfile 24 | - name: Update and/or publish versions 25 | id: changesets 26 | uses: changesets/action@v1 27 | with: 28 | version: pnpm run version 29 | publish: pnpm run release 30 | commit: "Update package versions" 31 | title: "Release: Update package versions" 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Marc Bouchenoire 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/site/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | /** 4 | * The 404 page component. 5 | */ 6 | function Page() { 7 | return ( 8 |
9 |

10 | Oops 11 |

12 |

13 | The page you are looking for doesn’t exist. 14 |

15 | 19 | Return to home page 20 | 21 |
22 | ) 23 | } 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /packages/typometer/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Unpack = T extends (infer U)[] ? U : T 2 | 3 | export type PlainObject = Record 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 | 37 | 42 | 43 | {children} 44 | 268 | 269 | / 270 | 271 |
272 |
273 | 298 | 299 |
300 | 301 |
302 | 322 |