describe-me 0.5.0-next.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -5
- package/dist-cli/chrome-user-agent.d.ts +5 -0
- package/dist-cli/chrome-user-agent.js +5 -0
- package/dist-cli/download.d.ts +27 -0
- package/dist-cli/download.js +132 -0
- package/dist-cli/find-remote-urls.d.ts +15 -0
- package/dist-cli/find-remote-urls.js +24 -0
- package/dist-cli/font-cache-dir.d.ts +6 -0
- package/dist-cli/font-cache-dir.js +25 -0
- package/dist-cli/font-cache.d.ts +25 -0
- package/dist-cli/font-cache.js +60 -0
- package/dist-cli/font-extension.d.ts +2 -0
- package/dist-cli/font-extension.js +15 -0
- package/dist-cli/font-provider.d.ts +17 -0
- package/dist-cli/font-provider.js +66 -0
- package/dist-cli/main.js +2 -2
- package/dist-cli/map-with-concurrency.d.ts +2 -0
- package/dist-cli/map-with-concurrency.js +13 -0
- package/dist-cli/parse-args.d.ts +6 -1
- package/dist-cli/parse-args.js +7 -1
- package/dist-cli/print-vendor-report.d.ts +3 -0
- package/dist-cli/print-vendor-report.js +35 -0
- package/dist-cli/run-build.d.ts +10 -2
- package/dist-cli/run-build.js +25 -3
- package/dist-cli/store-vendored-file.d.ts +2 -0
- package/dist-cli/store-vendored-file.js +13 -0
- package/dist-cli/vendor-font-file.d.ts +33 -0
- package/dist-cli/vendor-font-file.js +51 -0
- package/dist-cli/vendor-fonts.d.ts +39 -0
- package/dist-cli/vendor-fonts.js +225 -0
- package/dist-cli/vendor-stylesheet.d.ts +24 -0
- package/dist-cli/vendor-stylesheet.js +139 -0
- package/dist-cli/vendored-name.d.ts +2 -0
- package/dist-cli/vendored-name.js +5 -0
- package/package.json +2 -2
- package/src/collapsed-suites.ts +70 -0
- package/src/data.ts +3 -12
- package/src/diagnostics-menu.ts +54 -4
- package/src/keyboard.ts +31 -7
- package/src/main-panel.ts +1 -1
- package/src/main.ts +24 -5
- package/src/matches-query.ts +11 -0
- package/src/regions.ts +11 -2
- package/src/render-all.ts +3 -3
- package/src/render-tree.ts +42 -0
- package/src/resolve-test-id.ts +20 -0
- package/src/search-field.ts +49 -0
- package/src/sidebar.ts +110 -34
- package/src/sole-suite.ts +15 -0
- package/src/state-hash.ts +27 -0
- package/src/state.ts +16 -24
- package/src/style.css +72 -0
- package/src/sync-selection.ts +31 -0
- package/src/tree.ts +11 -2
- package/src/viewport-controls.ts +1 -1
- package/src/visible-tests.ts +57 -0
package/README.md
CHANGED
|
@@ -24,11 +24,24 @@ describe-me dev # viewer on http://localhost:6006, live while v
|
|
|
24
24
|
describe-me build --out docs # self-contained static site: viewer + __data/
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
| Flag
|
|
28
|
-
|
|
|
29
|
-
| `--data`
|
|
30
|
-
| `--out`
|
|
31
|
-
| `--port`
|
|
27
|
+
| Flag | Default | Meaning |
|
|
28
|
+
| ------------------- | -------------------- | ------------------------------------- |
|
|
29
|
+
| `--data` | `.describe-me` | The directory the reporter wrote. |
|
|
30
|
+
| `--out` | `describe-me-dist` | Output directory for `build`. |
|
|
31
|
+
| `--port` | `6006` | Port for `dev`. |
|
|
32
|
+
| `--no-vendor-fonts` | fonts are downloaded | `build`: keep web fonts on their CDN. |
|
|
33
|
+
|
|
34
|
+
The viewer has search, collapsible suites, keyboard navigation, custom
|
|
35
|
+
viewport sizes and shareable links
|
|
36
|
+
([Viewer](https://github.com/grzehub/describe-me#viewer)).
|
|
37
|
+
|
|
38
|
+
`build` downloads the web fonts that the preview head and the snapshots load
|
|
39
|
+
from Google Fonts, Bunny Fonts and Fontsource on jsDelivr into the site, so
|
|
40
|
+
the docs show them offline. Adobe Fonts stay remote, because their license
|
|
41
|
+
does not allow self-hosting, and so do fonts on any other host. The build
|
|
42
|
+
lists both. Downloads are cached in `node_modules/.cache/describe-me/fonts`.
|
|
43
|
+
A font that does not download keeps loading from the network, and the build
|
|
44
|
+
never fails because of it.
|
|
32
45
|
|
|
33
46
|
The static site uses relative URLs, so it works from a sub-path such as
|
|
34
47
|
GitHub Pages.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The User-Agent of a current desktop Chrome. Google Fonts serves woff2 split
|
|
3
|
+
* by `unicode-range` only to browsers it knows.
|
|
4
|
+
*/
|
|
5
|
+
export declare const CHROME_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The User-Agent of a current desktop Chrome. Google Fonts serves woff2 split
|
|
3
|
+
* by `unicode-range` only to browsers it knows.
|
|
4
|
+
*/
|
|
5
|
+
export const CHROME_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { FontKind } from './font-provider.js';
|
|
2
|
+
/** How to request one file. */
|
|
3
|
+
export interface DownloadOptions {
|
|
4
|
+
/** What the URL must stay after every redirect. */
|
|
5
|
+
kind: FontKind;
|
|
6
|
+
fetch: typeof fetch;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
maxBytes: number;
|
|
9
|
+
/** Hosts that failed on the network in this run, with the reason. Read and written. */
|
|
10
|
+
unreachableHosts: Map<string, string>;
|
|
11
|
+
}
|
|
12
|
+
/** A downloaded body, or why there is none. `network` marks a failure before any HTTP answer. */
|
|
13
|
+
export type DownloadResult = {
|
|
14
|
+
ok: true;
|
|
15
|
+
bytes: Uint8Array;
|
|
16
|
+
finalUrl: string;
|
|
17
|
+
contentType: string;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
reason: string;
|
|
21
|
+
network: boolean;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Download one file from an allowlisted `https:` URL. Redirects are followed
|
|
25
|
+
* by hand and must stay on the allowlist with the same kind. Never throws.
|
|
26
|
+
*/
|
|
27
|
+
export declare function download(url: URL, options: DownloadOptions): Promise<DownloadResult>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { CHROME_USER_AGENT } from './chrome-user-agent.js';
|
|
2
|
+
import { fontProvider } from './font-provider.js';
|
|
3
|
+
const MAX_REDIRECTS = 5;
|
|
4
|
+
const STYLESHEET_ACCEPT = 'text/css,*/*;q=0.1';
|
|
5
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
6
|
+
function failure(reason, network = false) {
|
|
7
|
+
return { ok: false, reason, network };
|
|
8
|
+
}
|
|
9
|
+
function megabytes(bytes) {
|
|
10
|
+
return `${Math.round(bytes / 1_000_000)} MB`;
|
|
11
|
+
}
|
|
12
|
+
/** Node rejects with `TypeError('fetch failed')` and keeps the system error code in `cause`. */
|
|
13
|
+
function networkReason(error, timeoutMs) {
|
|
14
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
15
|
+
return `no answer within ${timeoutMs / 1000} s`;
|
|
16
|
+
}
|
|
17
|
+
const code = error?.cause?.code;
|
|
18
|
+
if (typeof code === 'string') {
|
|
19
|
+
return code;
|
|
20
|
+
}
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
|
23
|
+
function headersFor(kind) {
|
|
24
|
+
if (kind === 'stylesheet') {
|
|
25
|
+
return { 'User-Agent': CHROME_USER_AGENT, Accept: STYLESHEET_ACCEPT };
|
|
26
|
+
}
|
|
27
|
+
return { 'User-Agent': CHROME_USER_AGENT };
|
|
28
|
+
}
|
|
29
|
+
/** The body, or null once it grows past `maxBytes`. */
|
|
30
|
+
async function readBody(response, maxBytes) {
|
|
31
|
+
const length = response.headers.get('content-length');
|
|
32
|
+
if (length !== null && Number(length) > maxBytes) {
|
|
33
|
+
await response.body?.cancel();
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (response.body === null) {
|
|
37
|
+
return new Uint8Array();
|
|
38
|
+
}
|
|
39
|
+
const reader = response.body.getReader();
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let total = 0;
|
|
42
|
+
let chunk = await reader.read();
|
|
43
|
+
while (!chunk.done) {
|
|
44
|
+
total += chunk.value.byteLength;
|
|
45
|
+
if (total > maxBytes) {
|
|
46
|
+
await reader.cancel();
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
chunks.push(chunk.value);
|
|
50
|
+
chunk = await reader.read();
|
|
51
|
+
}
|
|
52
|
+
return Buffer.concat(chunks);
|
|
53
|
+
}
|
|
54
|
+
/** `url` resolved against `base` and upgraded to `https:`, or null for other schemes. */
|
|
55
|
+
function httpsUrl(url, base) {
|
|
56
|
+
if (!URL.canParse(url, base)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const target = new URL(url, base);
|
|
60
|
+
if (target.protocol === 'http:') {
|
|
61
|
+
target.protocol = 'https:';
|
|
62
|
+
}
|
|
63
|
+
return target.protocol === 'https:' ? target : null;
|
|
64
|
+
}
|
|
65
|
+
/** The next hop of a redirect, checked against the allowlist before it is requested. */
|
|
66
|
+
function nextHop(response, current, kind) {
|
|
67
|
+
const location = response.headers.get('location');
|
|
68
|
+
if (location === null) {
|
|
69
|
+
return failure(`HTTP ${response.status} without a location`);
|
|
70
|
+
}
|
|
71
|
+
const target = httpsUrl(location, current);
|
|
72
|
+
if (target === null) {
|
|
73
|
+
return failure('redirect to an invalid URL');
|
|
74
|
+
}
|
|
75
|
+
const provider = fontProvider(target);
|
|
76
|
+
if (!provider?.vendorable || provider.kind !== kind) {
|
|
77
|
+
return failure(`redirect to ${target.hostname}, off the font allowlist`);
|
|
78
|
+
}
|
|
79
|
+
return target;
|
|
80
|
+
}
|
|
81
|
+
/** One request. A URL is the next hop of a redirect. */
|
|
82
|
+
async function request(url, options) {
|
|
83
|
+
const response = await options.fetch(url.href, {
|
|
84
|
+
headers: headersFor(options.kind),
|
|
85
|
+
redirect: 'manual',
|
|
86
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
87
|
+
});
|
|
88
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
89
|
+
await response.body?.cancel();
|
|
90
|
+
return nextHop(response, url, options.kind);
|
|
91
|
+
}
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
await response.body?.cancel();
|
|
94
|
+
return failure(`HTTP ${response.status}`);
|
|
95
|
+
}
|
|
96
|
+
const bytes = await readBody(response, options.maxBytes);
|
|
97
|
+
if (bytes === null) {
|
|
98
|
+
return failure(`larger than ${megabytes(options.maxBytes)}`);
|
|
99
|
+
}
|
|
100
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
101
|
+
return { ok: true, bytes, finalUrl: url.href, contentType };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Download one file from an allowlisted `https:` URL. Redirects are followed
|
|
105
|
+
* by hand and must stay on the allowlist with the same kind. Never throws.
|
|
106
|
+
*/
|
|
107
|
+
export async function download(url, options) {
|
|
108
|
+
let current = httpsUrl(url.href, url);
|
|
109
|
+
if (current === null) {
|
|
110
|
+
return failure('not an http(s) URL');
|
|
111
|
+
}
|
|
112
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
113
|
+
const unreachable = options.unreachableHosts.get(current.hostname);
|
|
114
|
+
if (unreachable !== undefined) {
|
|
115
|
+
return failure(unreachable, true);
|
|
116
|
+
}
|
|
117
|
+
let outcome;
|
|
118
|
+
try {
|
|
119
|
+
outcome = await request(current, options);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const reason = networkReason(error, options.timeoutMs);
|
|
123
|
+
options.unreachableHosts.set(current.hostname, reason);
|
|
124
|
+
return failure(reason, true);
|
|
125
|
+
}
|
|
126
|
+
if (!(outcome instanceof URL)) {
|
|
127
|
+
return outcome;
|
|
128
|
+
}
|
|
129
|
+
current = outcome;
|
|
130
|
+
}
|
|
131
|
+
return failure(`more than ${MAX_REDIRECTS} redirects`);
|
|
132
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A remote URL found in a text. */
|
|
2
|
+
export interface RemoteUrl {
|
|
3
|
+
/** The URL as written. A rewrite replaces it whole. */
|
|
4
|
+
raw: string;
|
|
5
|
+
/** The `https:` URL to request. */
|
|
6
|
+
url: string;
|
|
7
|
+
/** Offset of `raw` in the text. */
|
|
8
|
+
start: number;
|
|
9
|
+
/** Offset just past `raw`. */
|
|
10
|
+
end: number;
|
|
11
|
+
}
|
|
12
|
+
/** How the text is written. Only HTML escapes `&` as `&` inside URLs. */
|
|
13
|
+
export type TextSyntax = 'json' | 'css' | 'html';
|
|
14
|
+
/** Every remote URL in a snapshot, a stylesheet or the preview head, in source order. */
|
|
15
|
+
export declare function findRemoteUrls(text: string, syntax: TextSyntax): RemoteUrl[];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `http://`, `https://` and protocol-relative `//` URLs. A `//` after a word
|
|
3
|
+
* character, `:` or `/` is part of something else, such as `a//b`. A URL ends
|
|
4
|
+
* where JSON, CSS or HTML would end it: `\"` in JSON, `)` in an unquoted
|
|
5
|
+
* `url()`. Google Fonts URLs contain `;`, `&`, `@`, `,` and `:`, so those do not.
|
|
6
|
+
*/
|
|
7
|
+
const REMOTE_URL = /(?<![\w:/])(?:https?:)?\/\/[^\s"'`\\()<>]+/gi;
|
|
8
|
+
function requestUrl(raw, syntax) {
|
|
9
|
+
const decoded = syntax === 'html' ? raw.replaceAll('&', '&') : raw;
|
|
10
|
+
const scheme = decoded.slice(0, decoded.indexOf('//')).toLowerCase();
|
|
11
|
+
const https = scheme === 'https:' ? decoded : `https:${decoded.slice(scheme.length)}`;
|
|
12
|
+
return URL.canParse(https) ? new URL(https).href : null;
|
|
13
|
+
}
|
|
14
|
+
/** Every remote URL in a snapshot, a stylesheet or the preview head, in source order. */
|
|
15
|
+
export function findRemoteUrls(text, syntax) {
|
|
16
|
+
const found = [];
|
|
17
|
+
for (const match of text.matchAll(REMOTE_URL)) {
|
|
18
|
+
const url = requestUrl(match[0], syntax);
|
|
19
|
+
if (url !== null) {
|
|
20
|
+
found.push({ raw: match[0], url, start: match.index, end: match.index + match[0].length });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where downloaded fonts are cached: `node_modules/.cache/describe-me/fonts`
|
|
3
|
+
* in the nearest `node_modules` at or above the source data directory, as
|
|
4
|
+
* other build tools do. Without one it is a directory in the system temp dir.
|
|
5
|
+
*/
|
|
6
|
+
export declare function fontCacheDir(sourceDataDir: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
function isDirectory(path) {
|
|
5
|
+
return existsSync(path) && statSync(path).isDirectory();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Where downloaded fonts are cached: `node_modules/.cache/describe-me/fonts`
|
|
9
|
+
* in the nearest `node_modules` at or above the source data directory, as
|
|
10
|
+
* other build tools do. Without one it is a directory in the system temp dir.
|
|
11
|
+
*/
|
|
12
|
+
export function fontCacheDir(sourceDataDir) {
|
|
13
|
+
let dir = sourceDataDir;
|
|
14
|
+
while (true) {
|
|
15
|
+
const nodeModules = join(dir, 'node_modules');
|
|
16
|
+
if (isDirectory(nodeModules)) {
|
|
17
|
+
return join(nodeModules, '.cache', 'describe-me', 'fonts');
|
|
18
|
+
}
|
|
19
|
+
const parent = dirname(dir);
|
|
20
|
+
if (parent === dir) {
|
|
21
|
+
return join(tmpdir(), 'describe-me-fonts');
|
|
22
|
+
}
|
|
23
|
+
dir = parent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A cached response that passed validation. */
|
|
2
|
+
export interface CachedResponse {
|
|
3
|
+
/** The URL as requested. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** The URL after redirects, which relative URLs inside a stylesheet resolve against. */
|
|
6
|
+
finalUrl: string;
|
|
7
|
+
contentType: string;
|
|
8
|
+
/** `Date.now()` of the download. */
|
|
9
|
+
fetchedAt: number;
|
|
10
|
+
body: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Downloaded stylesheets and fonts on disk, keyed by User-Agent and URL, so a
|
|
14
|
+
* build works offline after one online build. Every problem counts as a miss.
|
|
15
|
+
*/
|
|
16
|
+
export declare class FontCache {
|
|
17
|
+
private readonly dir;
|
|
18
|
+
private readonly userAgent;
|
|
19
|
+
constructor(dir: string, userAgent: string);
|
|
20
|
+
/** The entry for `url`, or null when there is none or it cannot be read. */
|
|
21
|
+
read(url: string): CachedResponse | null;
|
|
22
|
+
/** The body goes first, so an entry whose metadata exists is complete. */
|
|
23
|
+
write(entry: CachedResponse): void;
|
|
24
|
+
private key;
|
|
25
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
function isMeta(value, url) {
|
|
5
|
+
const meta = value;
|
|
6
|
+
return (typeof meta === 'object' &&
|
|
7
|
+
meta !== null &&
|
|
8
|
+
meta.url === url &&
|
|
9
|
+
typeof meta.finalUrl === 'string' &&
|
|
10
|
+
URL.canParse(meta.finalUrl) &&
|
|
11
|
+
typeof meta.contentType === 'string' &&
|
|
12
|
+
typeof meta.fetchedAt === 'number');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Downloaded stylesheets and fonts on disk, keyed by User-Agent and URL, so a
|
|
16
|
+
* build works offline after one online build. Every problem counts as a miss.
|
|
17
|
+
*/
|
|
18
|
+
export class FontCache {
|
|
19
|
+
dir;
|
|
20
|
+
userAgent;
|
|
21
|
+
constructor(dir, userAgent) {
|
|
22
|
+
this.dir = dir;
|
|
23
|
+
this.userAgent = userAgent;
|
|
24
|
+
}
|
|
25
|
+
/** The entry for `url`, or null when there is none or it cannot be read. */
|
|
26
|
+
read(url) {
|
|
27
|
+
const key = this.key(url);
|
|
28
|
+
try {
|
|
29
|
+
const meta = JSON.parse(readFileSync(join(this.dir, `${key}.json`), 'utf8'));
|
|
30
|
+
if (!isMeta(meta, url)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return { ...meta, body: readFileSync(join(this.dir, `${key}.body`)) };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** The body goes first, so an entry whose metadata exists is complete. */
|
|
40
|
+
write(entry) {
|
|
41
|
+
const key = this.key(entry.url);
|
|
42
|
+
const meta = {
|
|
43
|
+
url: entry.url,
|
|
44
|
+
finalUrl: entry.finalUrl,
|
|
45
|
+
contentType: entry.contentType,
|
|
46
|
+
fetchedAt: entry.fetchedAt,
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(this.dir, { recursive: true });
|
|
50
|
+
writeFileSync(join(this.dir, `${key}.body`), entry.body);
|
|
51
|
+
writeFileSync(join(this.dir, `${key}.json`), JSON.stringify(meta));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// A read-only cache only costs the next build a download.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
key(url) {
|
|
58
|
+
return createHash('sha1').update(`${this.userAgent}\n${url}`).digest('hex');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** File signatures of the font formats a browser loads, read from the first four bytes. */
|
|
2
|
+
const SIGNATURES = [
|
|
3
|
+
[[0x77, 0x4f, 0x46, 0x32], '.woff2'],
|
|
4
|
+
[[0x77, 0x4f, 0x46, 0x46], '.woff'],
|
|
5
|
+
[[0x4f, 0x54, 0x54, 0x4f], '.otf'],
|
|
6
|
+
[[0x00, 0x01, 0x00, 0x00], '.ttf'],
|
|
7
|
+
[[0x74, 0x72, 0x75, 0x65], '.ttf'],
|
|
8
|
+
];
|
|
9
|
+
/** The extension of a font file from its magic bytes, or null when the bytes are not a font. */
|
|
10
|
+
export function fontExtension(bytes) {
|
|
11
|
+
const match = SIGNATURES.find(([signature]) => {
|
|
12
|
+
return signature.every((byte, i) => bytes[i] === byte);
|
|
13
|
+
});
|
|
14
|
+
return match === undefined ? null : match[1];
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** What a URL on a font host points at. */
|
|
2
|
+
export type FontKind = 'stylesheet' | 'font';
|
|
3
|
+
/** A font host that serves a URL: vendorable with its kind, or remote for a reason. */
|
|
4
|
+
export type FontProvider = {
|
|
5
|
+
name: string;
|
|
6
|
+
vendorable: true;
|
|
7
|
+
kind: FontKind;
|
|
8
|
+
} | {
|
|
9
|
+
name: string;
|
|
10
|
+
vendorable: false;
|
|
11
|
+
reason: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The font host behind a URL, or null when the URL is not a stylesheet or a
|
|
15
|
+
* font of a known host. Bare origins, such as a `preconnect`, are null too.
|
|
16
|
+
*/
|
|
17
|
+
export declare function fontProvider(url: URL): FontProvider | null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const FONT_EXTENSIONS = ['.woff2', '.woff', '.ttf', '.otf'];
|
|
2
|
+
const FONTSOURCE_PACKAGES = ['/npm/@fontsource/', '/npm/@fontsource-variable/'];
|
|
3
|
+
const FONTSOURCE_FILES = '/fontsource/fonts/';
|
|
4
|
+
const GOOGLE_STYLESHEETS = new Set(['/css', '/css2', '/icon']);
|
|
5
|
+
const BUNNY_STYLESHEETS = new Set(['/css', '/css2']);
|
|
6
|
+
const ADOBE_HOSTS = new Set([
|
|
7
|
+
'use.typekit.net',
|
|
8
|
+
'use.typekit.com',
|
|
9
|
+
'p.typekit.net',
|
|
10
|
+
'fonts.adobe.com',
|
|
11
|
+
]);
|
|
12
|
+
const ADOBE = {
|
|
13
|
+
name: 'Adobe Fonts',
|
|
14
|
+
vendorable: false,
|
|
15
|
+
reason: 'Adobe Fonts do not allow self-hosting',
|
|
16
|
+
};
|
|
17
|
+
function hasFontExtension(pathname) {
|
|
18
|
+
const lower = pathname.toLowerCase();
|
|
19
|
+
return FONT_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
20
|
+
}
|
|
21
|
+
function googleApis(pathname) {
|
|
22
|
+
return GOOGLE_STYLESHEETS.has(pathname) ? 'stylesheet' : null;
|
|
23
|
+
}
|
|
24
|
+
function googleStatic(pathname) {
|
|
25
|
+
return pathname === '/' ? null : 'font';
|
|
26
|
+
}
|
|
27
|
+
function bunny(pathname) {
|
|
28
|
+
if (BUNNY_STYLESHEETS.has(pathname)) {
|
|
29
|
+
return 'stylesheet';
|
|
30
|
+
}
|
|
31
|
+
return pathname.includes('/files/') ? 'font' : null;
|
|
32
|
+
}
|
|
33
|
+
function fontsource(pathname) {
|
|
34
|
+
if (FONTSOURCE_PACKAGES.some((prefix) => pathname.startsWith(prefix))) {
|
|
35
|
+
if (pathname.toLowerCase().endsWith('.css')) {
|
|
36
|
+
return 'stylesheet';
|
|
37
|
+
}
|
|
38
|
+
return hasFontExtension(pathname) ? 'font' : null;
|
|
39
|
+
}
|
|
40
|
+
return pathname.startsWith(FONTSOURCE_FILES) ? 'font' : null;
|
|
41
|
+
}
|
|
42
|
+
/** The allowlist, by exact hostname. */
|
|
43
|
+
const HOSTS = new Map([
|
|
44
|
+
['fonts.googleapis.com', { name: 'Google Fonts', kind: googleApis }],
|
|
45
|
+
['fonts.gstatic.com', { name: 'Google Fonts', kind: googleStatic }],
|
|
46
|
+
['fonts.bunny.net', { name: 'Bunny Fonts', kind: bunny }],
|
|
47
|
+
['cdn.jsdelivr.net', { name: 'Fontsource on jsDelivr', kind: fontsource }],
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* The font host behind a URL, or null when the URL is not a stylesheet or a
|
|
51
|
+
* font of a known host. Bare origins, such as a `preconnect`, are null too.
|
|
52
|
+
*/
|
|
53
|
+
export function fontProvider(url) {
|
|
54
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.port !== '') {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (ADOBE_HOSTS.has(url.hostname)) {
|
|
58
|
+
return url.pathname === '/' ? null : ADOBE;
|
|
59
|
+
}
|
|
60
|
+
const host = HOSTS.get(url.hostname);
|
|
61
|
+
const kind = host?.kind(url.pathname) ?? null;
|
|
62
|
+
if (host === undefined || kind === null) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return { name: host.name, vendorable: true, kind };
|
|
66
|
+
}
|
package/dist-cli/main.js
CHANGED
|
@@ -4,7 +4,7 @@ import { runDev } from './run-dev.js';
|
|
|
4
4
|
const USAGE = `describe-me — living component docs from your tests
|
|
5
5
|
|
|
6
6
|
describe-me dev [--data .describe-me] [--port 6006] viewer with live updates
|
|
7
|
-
describe-me build [--data .describe-me] [--out describe-me-dist] static site
|
|
7
|
+
describe-me build [--data .describe-me] [--out describe-me-dist] [--no-vendor-fonts] static site
|
|
8
8
|
`;
|
|
9
9
|
/** Entry point of the `describe-me` binary. */
|
|
10
10
|
async function main() {
|
|
@@ -14,7 +14,7 @@ async function main() {
|
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
16
|
if (options.command === 'build') {
|
|
17
|
-
await runBuild(options.data, options.out);
|
|
17
|
+
await runBuild(options.data, options.out, { vendorFonts: options.vendorFonts });
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
await runDev(options.data, options.port);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Run `run` over `items` with at most `limit` calls in flight, and keep the results in order. */
|
|
2
|
+
export async function mapWithConcurrency(items, limit, run) {
|
|
3
|
+
const results = new Array(items.length);
|
|
4
|
+
let next = 0;
|
|
5
|
+
const worker = async () => {
|
|
6
|
+
while (next < items.length) {
|
|
7
|
+
const index = next++;
|
|
8
|
+
results[index] = await run(items[index]);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
12
|
+
return results;
|
|
13
|
+
}
|
package/dist-cli/parse-args.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ export interface CliOptions {
|
|
|
7
7
|
out: string;
|
|
8
8
|
/** Port for `dev`. */
|
|
9
9
|
port: number;
|
|
10
|
+
/** `build`: download web fonts from known font hosts into the site. */
|
|
11
|
+
vendorFonts: boolean;
|
|
10
12
|
}
|
|
11
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Turn `describe-me <command> [--data dir] [--out dir] [--port n] [--no-vendor-fonts]`
|
|
15
|
+
* into options with defaults.
|
|
16
|
+
*/
|
|
12
17
|
export declare function parseArgs(argv: string[]): CliOptions;
|
package/dist-cli/parse-args.js
CHANGED
|
@@ -8,16 +8,21 @@ function commandFrom(positional, help) {
|
|
|
8
8
|
}
|
|
9
9
|
return 'dev';
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Turn `describe-me <command> [--data dir] [--out dir] [--port n] [--no-vendor-fonts]`
|
|
13
|
+
* into options with defaults.
|
|
14
|
+
*/
|
|
12
15
|
export function parseArgs(argv) {
|
|
13
16
|
const { values, positionals } = parseNodeArgs({
|
|
14
17
|
args: argv,
|
|
15
18
|
allowPositionals: true,
|
|
19
|
+
allowNegative: true,
|
|
16
20
|
options: {
|
|
17
21
|
data: { type: 'string', default: '.describe-me' },
|
|
18
22
|
out: { type: 'string', default: 'describe-me-dist' },
|
|
19
23
|
port: { type: 'string', default: '6006' },
|
|
20
24
|
help: { type: 'boolean', short: 'h', default: false },
|
|
25
|
+
'vendor-fonts': { type: 'boolean', default: true },
|
|
21
26
|
},
|
|
22
27
|
});
|
|
23
28
|
return {
|
|
@@ -25,5 +30,6 @@ export function parseArgs(argv) {
|
|
|
25
30
|
data: values.data,
|
|
26
31
|
out: values.out,
|
|
27
32
|
port: Number(values.port),
|
|
33
|
+
vendorFonts: values['vendor-fonts'],
|
|
28
34
|
};
|
|
29
35
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const MAX_FAILURE_LINES = 5;
|
|
2
|
+
function plural(count, word) {
|
|
3
|
+
return `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
4
|
+
}
|
|
5
|
+
function size(bytes) {
|
|
6
|
+
if (bytes < 1_000_000) {
|
|
7
|
+
return `${Math.round(bytes / 1000)} kB`;
|
|
8
|
+
}
|
|
9
|
+
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
10
|
+
}
|
|
11
|
+
function summary(report) {
|
|
12
|
+
const parts = report.stylesheets.map(({ host, count }) => {
|
|
13
|
+
return `${plural(count, 'stylesheet')} from ${host}`;
|
|
14
|
+
});
|
|
15
|
+
parts.push(`${plural(report.fontFiles, 'font file')} (${size(report.fontBytes)})`);
|
|
16
|
+
const last = parts.pop();
|
|
17
|
+
const listed = parts.length === 0 ? last : `${parts.join(', ')} and ${last}`;
|
|
18
|
+
return `describe-me: vendored ${listed} into __data/assets`;
|
|
19
|
+
}
|
|
20
|
+
/** Tell the user what `describe-me build` vendored and what keeps loading from the network. */
|
|
21
|
+
export function printVendorReport(report) {
|
|
22
|
+
if (report.stylesheets.length > 0 || report.fontFiles > 0) {
|
|
23
|
+
console.log(summary(report));
|
|
24
|
+
}
|
|
25
|
+
for (const { host, reason } of report.leftRemote) {
|
|
26
|
+
console.log(`describe-me: left remote: ${host} (${reason})`);
|
|
27
|
+
}
|
|
28
|
+
for (const { url, reason } of report.failures.slice(0, MAX_FAILURE_LINES)) {
|
|
29
|
+
console.warn(`describe-me: ${url} did not download (${reason}). It keeps loading from the network.`);
|
|
30
|
+
}
|
|
31
|
+
const more = report.failures.length - MAX_FAILURE_LINES;
|
|
32
|
+
if (more > 0) {
|
|
33
|
+
console.warn(`describe-me: and ${more} more that did not download.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist-cli/run-build.d.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export
|
|
1
|
+
/** What `build` does besides the Vite build. */
|
|
2
|
+
export interface RunBuildOptions {
|
|
3
|
+
/** Download web fonts from known font hosts into the site. */
|
|
4
|
+
vendorFonts: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Produce a self-contained static site: the viewer plus the data directory
|
|
8
|
+
* under `__data/`. Font vendoring works on that copy and never fails the build.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runBuild(data: string, out: string, options: RunBuildOptions): Promise<void>;
|
package/dist-cli/run-build.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
2
3
|
import { build } from 'vite';
|
|
4
|
+
import { fontCacheDir } from './font-cache-dir.js';
|
|
5
|
+
import { printVendorReport } from './print-vendor-report.js';
|
|
6
|
+
import { vendorFonts } from './vendor-fonts.js';
|
|
3
7
|
import { viewerRoot } from './viewer-root.js';
|
|
4
|
-
/**
|
|
5
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Produce a self-contained static site: the viewer plus the data directory
|
|
10
|
+
* under `__data/`. Font vendoring works on that copy and never fails the build.
|
|
11
|
+
*/
|
|
12
|
+
export async function runBuild(data, out, options) {
|
|
6
13
|
process.env.DESCRIBE_ME_DIR = resolve(data);
|
|
7
14
|
const root = viewerRoot();
|
|
8
15
|
await build({
|
|
@@ -10,4 +17,19 @@ export async function runBuild(data, out) {
|
|
|
10
17
|
configFile: resolve(root, 'vite.config.ts'),
|
|
11
18
|
build: { outDir: resolve(out), emptyOutDir: true },
|
|
12
19
|
});
|
|
20
|
+
if (!options.vendorFonts) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// The Vite plugin has already warned when there was no data to copy.
|
|
24
|
+
const dataDir = join(resolve(out), '__data');
|
|
25
|
+
if (!existsSync(dataDir)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
printVendorReport(await vendorFonts(dataDir, { cacheDir: fontCacheDir(resolve(data)) }));
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
console.warn(`describe-me: font vendoring stopped (${message}). Fonts keep loading from the network.`);
|
|
34
|
+
}
|
|
13
35
|
}
|