@abreen/tada 1.18.5 → 1.19.1
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 +77 -31
- package/bin/tada.ts +17 -8
- package/bin/validators.ts +6 -5
- package/build/bundle.ts +1 -1
- package/build/config-loader.ts +8 -0
- package/build/reachability.ts +18 -54
- package/build/site-variables.ts +24 -4
- package/build/template-globals.ts +3 -0
- package/build/utils/render.ts +2 -2
- package/build/utils/symbol.ts +21 -0
- package/build/utils/trace-layout.ts +2 -14
- package/package.json +1 -1
- package/schema/site.schema.json +5 -2
- package/src/_content.scss +1 -1
- package/src/_icons.scss +1 -1
- package/src/_layout.scss +1 -0
- package/src/header/_index.scss +11 -0
- package/src/index.ts +4 -2
- package/src/navigate/_index.scss +5 -0
- package/src/navigate/runtime.ts +21 -4
- package/src/question/_index.scss +1 -1
- package/src/question/index.ts +7 -0
- package/src/search/index.ts +10 -60
- package/src/top/README.md +7 -2
- package/src/top/index.ts +25 -3
- package/templates/_top.html +5 -8
package/README.md
CHANGED
|
@@ -5,37 +5,62 @@
|
|
|
5
5
|
|
|
6
6
|
A static site generator. The successor to Presto.
|
|
7
7
|
|
|
8
|
+
<figure>
|
|
9
|
+
<a href="https://abreen.io/Tada/markdown.html" target="_blank">
|
|
10
|
+
<img src=".github/screenshot.png" alt="Four screenshots of the Tada example site">
|
|
11
|
+
</a>
|
|
12
|
+
<figcaption>
|
|
13
|
+
A screenshot of the Tada example site in:
|
|
14
|
+
<ul>
|
|
15
|
+
<li>light mode</li>
|
|
16
|
+
<li>dark mode, high contrast</li>
|
|
17
|
+
<li>light mode, high contrast, & serif fonts</li>
|
|
18
|
+
<li>dark mode, standard contrast</li>
|
|
19
|
+
</ul>
|
|
20
|
+
</figcaption>
|
|
21
|
+
</figure>
|
|
22
|
+
|
|
23
|
+
[Visit the example site ↗](https://abreen.io/Tada/index.html)
|
|
24
|
+
|
|
25
|
+
|
|
8
26
|
## Features
|
|
9
27
|
|
|
10
|
-
- Modern design
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
28
|
+
- Modern design
|
|
29
|
+
* Light & dark mode following system, customizable tint
|
|
30
|
+
* Standard & high contrast picker
|
|
31
|
+
* Sans-serif & serif font picker
|
|
32
|
+
* Plus, provide your own web fonts (with many tuning options)
|
|
33
|
+
|
|
34
|
+
- LaTeX math support
|
|
35
|
+
* Provided by [KaTeX][katex]
|
|
36
|
+
* Rendered completely at build time (zero load time on the client)
|
|
37
|
+
|
|
38
|
+
- Automatically generated favicon
|
|
39
|
+
* Text, color, font and font weight taken from config file
|
|
40
|
+
* Or, provide your own favicon file
|
|
41
|
+
|
|
42
|
+
- High quality output and ergonomic authoring
|
|
43
|
+
* Exhaustive internal link validation (broken links fail the build)
|
|
44
|
+
* External link handling (special visual treatment for external links,
|
|
45
|
+
no need to type `target="_blank"`)
|
|
46
|
+
* Internal links automatically prefixed with base path, if specified (no
|
|
47
|
+
need to keep writing the same URL prefix everywhere)
|
|
48
|
+
|
|
18
49
|
- Built-in search powered by [Pagefind][pagefind]
|
|
19
50
|
* Only pages in `content/` reachable from `/index.html` are indexed
|
|
51
|
+
* If `mutool` is installed, text inside PDFs in `content/` is indexed
|
|
52
|
+
|
|
20
53
|
- Generated HTML pages for source code
|
|
21
54
|
* Automatic code highlighting, clickable line numbers
|
|
22
55
|
* Dynamic table of contents for each method/function
|
|
23
56
|
* Converts new Markdown comment syntax ([added in Java 23][jep467]) to HTML
|
|
24
57
|
* Indexed by Pagefind (classes, interfaces, methods, and fields)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
* Hold Shift while annotating to erase
|
|
32
|
-
- PDF files are copied into `dist/`
|
|
33
|
-
* Text of each PDF page is extracted using `mutool` (if present) and indexed
|
|
34
|
-
- External link handling (special visual treatment for external links)
|
|
35
|
-
- Internal link validation at build time (broken links fail the build)
|
|
36
|
-
- Internal links automatically prefixed with base path, if specified
|
|
37
|
-
- Time zone chooser (automatically adjusts `<datetime>` elements)
|
|
38
|
-
- LaTeX math rendered at build time via [KaTeX][katex]
|
|
58
|
+
|
|
59
|
+
- Dynamic table of contents
|
|
60
|
+
* Floats on the side of the screen when window is large enough
|
|
61
|
+
* Renders headings, alert boxes, and `<hr>` elements
|
|
62
|
+
* Highlights the heading currently being viewed
|
|
63
|
+
|
|
39
64
|
- Extended Markdown syntax
|
|
40
65
|
* `<<< details ... <<<` renders a collapsible box
|
|
41
66
|
* `::: section ... :::` renders a special section with a fancy background
|
|
@@ -45,9 +70,28 @@ A static site generator. The successor to Presto.
|
|
|
45
70
|
* `+++ ... +++ ... +++` renders a two-column layout
|
|
46
71
|
* Special heading subtitles with `## Heading # A subtitle here`
|
|
47
72
|
* `{{{ _partial.md }}}` syntax for including partials
|
|
48
|
-
|
|
49
|
-
-
|
|
50
|
-
*
|
|
73
|
+
|
|
74
|
+
- Extremely efficient client-side navigation
|
|
75
|
+
* A miniature SPA that uses internal links tagged at build time
|
|
76
|
+
* Get the benefits of DOM tree replacement without an entire bloated framework
|
|
77
|
+
* Thoroughly covered by browser tests across multiple engines
|
|
78
|
+
* Clear loading indicators for slow requests
|
|
79
|
+
* Also detects a new version of the current page and supplies a reload button
|
|
80
|
+
|
|
81
|
+
### Experimental features
|
|
82
|
+
|
|
83
|
+
- Interactive execution traces via `renderTrace()`
|
|
84
|
+
* Supports Java and Python source files
|
|
85
|
+
* Renders step-by-step source highlighting, output, and memory diagrams
|
|
86
|
+
|
|
87
|
+
- Slides mode: separate content by `---` and present sections as full-screen slides
|
|
88
|
+
* Click icon near slide titles to start presenting at that slide
|
|
89
|
+
* Right-click in presentation mode to annotate
|
|
90
|
+
* Hold Shift while annotating to erase
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
- Time zone chooser (automatically adjusts `<datetime>` elements)
|
|
51
95
|
|
|
52
96
|
## Prerequisites
|
|
53
97
|
|
|
@@ -107,9 +151,11 @@ Visit [http://localhost:8080/index.html](http://localhost:8080/index.html).
|
|
|
107
151
|
|
|
108
152
|
Create a new Tada site in a new directory. Prompts for:
|
|
109
153
|
- **Site title**: displayed in the header and `<title>` tag
|
|
110
|
-
- **Symbol**: short text (1-5 uppercase characters) shown in the logo and favicon
|
|
111
|
-
|
|
112
|
-
|
|
154
|
+
- **Symbol**: short text (1-5 uppercase characters) shown in the logo and favicon,
|
|
155
|
+
or a single emoji shown in the logo (defaults to 🎉; emoji symbols turn off
|
|
156
|
+
generated favicons)
|
|
157
|
+
- **Theme color**: CSS color, e.g. `hsl(195 70% 40%)` (defaults to `darkturquoise`)
|
|
158
|
+
- **Background tint hue**: hue (0-360) for background/foreground tinting (defaults to `190`)
|
|
113
159
|
- **Background tint amount**: percentage (0-100) of tint to apply (defaults to `100`)
|
|
114
160
|
- **Default time zone**: for `<time>` elements (defaults to your system zone)
|
|
115
161
|
- **Production base URL**: e.g. `https://example.edu`
|
|
@@ -268,16 +314,16 @@ vars:
|
|
|
268
314
|
| `title` | Title for the whole site (also used to derive `titlePostfix`) |
|
|
269
315
|
| `titlePostfix` | *Optional*, the string to append to each page's `title` |
|
|
270
316
|
| `banner` | *Optional*, Markdown rendered in a bordered box above every page |
|
|
271
|
-
| `symbol` | Text (1-5 chars) displayed in header when `logo` is absent; also used for generated favicons |
|
|
317
|
+
| `symbol` | Text (1-5 chars) or a single emoji displayed in header when `logo` is absent; also used for generated favicons |
|
|
272
318
|
| `logo` | *Optional*, image path relative to `public/`, replacing the header symbol |
|
|
273
319
|
| `favicon` | *Optional*, `.ico` path relative to `public/`, replacing generated favicons and the web app manifest |
|
|
274
320
|
| `themeColor` | Theme color for the site (e.g., `"tomato"`, `"#c04040"`, `"hsl(195 70% 40%)"`) |
|
|
275
|
-
| `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `
|
|
321
|
+
| `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `190`) |
|
|
276
322
|
| `tintAmount` | *Optional*, percentage (0-100) of tint to apply to the neutral palette (default `100`); link saturation stays fixed |
|
|
277
323
|
| `defaultFont` | *Optional*, initial font pairing: `sans` or `serif` (default `sans`) |
|
|
278
324
|
| `defaultContrast` | *Optional*, initial neutral-palette contrast: `standard` or `high` (default `standard`) |
|
|
279
325
|
| `fontOverrides` | *Optional*, custom serif and serif-monospace WOFF2 faces from `public/`; each configured family requires `regular` and may include styled faces, OpenType `features`, and font-specific `tuning` |
|
|
280
|
-
| `faviconSymbol` | *Optional*, the text to use instead of `symbol` in generated favicons |
|
|
326
|
+
| `faviconSymbol` | *Optional*, the text to use instead of `symbol` in generated favicons; required when `symbol` is an emoji and favicons are generated |
|
|
281
327
|
| `features.search` | Enable search UI and Pagefind index generation |
|
|
282
328
|
| `features.favicon` | Enable favicon links and generation (default `true`); `false` also omits custom favicon links |
|
|
283
329
|
| `features.footer` | Show the Tada footer at the bottom of every page |
|
package/bin/tada.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
resolveSiteConfigFile,
|
|
28
28
|
type SiteEnv,
|
|
29
29
|
} from '../build/config-files';
|
|
30
|
+
import { isEmojiSymbol } from '../build/utils/symbol';
|
|
30
31
|
|
|
31
32
|
const { version } = packageJson;
|
|
32
33
|
|
|
@@ -49,7 +50,7 @@ function parsePortArg(name: string): number | undefined {
|
|
|
49
50
|
const idx = process.argv.indexOf(name);
|
|
50
51
|
if (idx !== -1 && process.argv[idx + 1]) {
|
|
51
52
|
const val = parseInt(process.argv[idx + 1], 10);
|
|
52
|
-
if (val
|
|
53
|
+
if (val >= 0 && val < 65536) {
|
|
53
54
|
return val;
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -75,22 +76,22 @@ interface InitQuestion {
|
|
|
75
76
|
const INIT_QUESTIONS: Record<string, InitQuestion> = {
|
|
76
77
|
title: {
|
|
77
78
|
prompt: 'Site title',
|
|
78
|
-
defaultValue: '
|
|
79
|
+
defaultValue: 'Tada',
|
|
79
80
|
validate: (v: string) => (v ? null : 'Title is required'),
|
|
80
81
|
},
|
|
81
82
|
symbol: {
|
|
82
|
-
prompt: 'Logo symbol (1-5 uppercase chars)',
|
|
83
|
-
defaultValue: '
|
|
83
|
+
prompt: 'Logo symbol (1-5 uppercase chars or one emoji)',
|
|
84
|
+
defaultValue: '🎉',
|
|
84
85
|
validate: validateSymbol,
|
|
85
86
|
},
|
|
86
87
|
themeColor: {
|
|
87
88
|
prompt: 'Theme color',
|
|
88
|
-
defaultValue: '
|
|
89
|
+
defaultValue: 'darkturquoise',
|
|
89
90
|
validate: validateColor,
|
|
90
91
|
},
|
|
91
92
|
tintHue: {
|
|
92
93
|
prompt: 'Background tint hue (0-360)',
|
|
93
|
-
defaultValue: '
|
|
94
|
+
defaultValue: '190',
|
|
94
95
|
validate: validateHue,
|
|
95
96
|
},
|
|
96
97
|
tintAmount: {
|
|
@@ -338,6 +339,14 @@ async function initCommand(args: string[]): Promise<void> {
|
|
|
338
339
|
// Create project directory
|
|
339
340
|
fs.mkdirSync(projectDir);
|
|
340
341
|
|
|
342
|
+
// Generated favicons are text-only, so an emoji symbol starts without them
|
|
343
|
+
const features = {
|
|
344
|
+
search: true,
|
|
345
|
+
favicon: !isEmojiSymbol(symbol),
|
|
346
|
+
footer: true,
|
|
347
|
+
pickers: true,
|
|
348
|
+
};
|
|
349
|
+
|
|
341
350
|
// Generate site configs
|
|
342
351
|
const devConfig = createSiteConfig({
|
|
343
352
|
title,
|
|
@@ -349,7 +358,7 @@ async function initCommand(args: string[]): Promise<void> {
|
|
|
349
358
|
base: 'http://localhost:8080',
|
|
350
359
|
basePath: '/',
|
|
351
360
|
internalDomains: ['localhost'],
|
|
352
|
-
features
|
|
361
|
+
features,
|
|
353
362
|
extensionToShikiLanguage: bare ? {} : { java: 'java', py: 'python' },
|
|
354
363
|
shikiLanguages: bare ? [] : ['java', 'python', 'html'],
|
|
355
364
|
});
|
|
@@ -364,7 +373,7 @@ async function initCommand(args: string[]): Promise<void> {
|
|
|
364
373
|
base: prodBase,
|
|
365
374
|
basePath: prodBasePath,
|
|
366
375
|
internalDomains: [prodDomain],
|
|
367
|
-
features
|
|
376
|
+
features,
|
|
368
377
|
extensionToShikiLanguage: bare ? {} : { java: 'java', py: 'python' },
|
|
369
378
|
shikiLanguages: bare ? [] : ['java', 'python', 'html'],
|
|
370
379
|
});
|
package/bin/validators.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import timezones from '../src/timezone/timezones.json' with { type: 'json' };
|
|
2
2
|
import type { SiteConfigInput, SiteVariables } from '../build/types';
|
|
3
|
+
import { isValidSymbol } from '../build/utils/symbol';
|
|
3
4
|
|
|
4
5
|
const SUPPORTED_TIME_ZONES: readonly string[] = timezones.map(tz => tz.value);
|
|
5
6
|
const FALLBACK_TIME_ZONE = 'UTC';
|
|
@@ -8,13 +9,13 @@ export function validateSymbol(value: string): string | null {
|
|
|
8
9
|
if (!value) {
|
|
9
10
|
return 'Symbol is required';
|
|
10
11
|
}
|
|
11
|
-
if (value
|
|
12
|
-
return
|
|
12
|
+
if (isValidSymbol(value)) {
|
|
13
|
+
return null;
|
|
13
14
|
}
|
|
14
|
-
if (
|
|
15
|
-
return 'Symbol must
|
|
15
|
+
if (value.length > 5) {
|
|
16
|
+
return 'Symbol must be 5 characters or fewer, or a single emoji';
|
|
16
17
|
}
|
|
17
|
-
return
|
|
18
|
+
return 'Symbol must contain only uppercase letters, digits, hyphens, and spaces, or be a single emoji';
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function validateColor(value: string): string | null {
|
package/build/bundle.ts
CHANGED
|
@@ -51,7 +51,7 @@ function renderThemeScss(siteVariables: SiteVariables): string {
|
|
|
51
51
|
const templatePath = path.join(getPackageDir(), 'templates/_theme.scss');
|
|
52
52
|
const template = fs.readFileSync(templatePath, 'utf-8');
|
|
53
53
|
const theme = deriveTheme(siteVariables.themeColor);
|
|
54
|
-
const tintHue = siteVariables.tintHue ??
|
|
54
|
+
const tintHue = siteVariables.tintHue ?? 190;
|
|
55
55
|
const tintAmount = siteVariables.tintAmount ?? 100;
|
|
56
56
|
|
|
57
57
|
const linkHue = formatCssNumber(deriveLinkHue(tintHue));
|
package/build/config-loader.ts
CHANGED
|
@@ -29,6 +29,14 @@ export function parseConfigText(text: string, fileName: string): unknown {
|
|
|
29
29
|
try {
|
|
30
30
|
return Bun.YAML.parse(text);
|
|
31
31
|
} catch (error) {
|
|
32
|
+
// YAML rejects JSON surrogate pair escapes such as emoji in `\ud83c\udf89` form
|
|
33
|
+
if (fileName.endsWith('.json')) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(text);
|
|
36
|
+
} catch {
|
|
37
|
+
// Report the YAML error, as for any other config
|
|
38
|
+
}
|
|
39
|
+
}
|
|
32
40
|
const message = error instanceof Error ? error.message : String(error);
|
|
33
41
|
throw new Error(`${fileName}: ${message}`, { cause: error });
|
|
34
42
|
}
|
package/build/reachability.ts
CHANGED
|
@@ -2,59 +2,24 @@ import path from 'path';
|
|
|
2
2
|
import { normalizeOutputPath } from './util';
|
|
3
3
|
import type { HtmlOutputAnalysis } from './types';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
return normalizeOutputPath(target);
|
|
7
|
-
}
|
|
8
|
-
|
|
5
|
+
/** HTML candidates for an already normalized target without a non-HTML extension. */
|
|
9
6
|
function toCandidateHtmlAssetPaths(target: string): string[] {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (normalizedTarget === '/') {
|
|
7
|
+
if (target === '/') {
|
|
13
8
|
return ['index.html'];
|
|
14
9
|
}
|
|
15
10
|
|
|
16
|
-
if (
|
|
17
|
-
return [
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
if (path.posix.extname(normalizedTarget)) {
|
|
21
|
-
return [];
|
|
11
|
+
if (target.endsWith('.html')) {
|
|
12
|
+
return [target.slice(1)];
|
|
22
13
|
}
|
|
23
14
|
|
|
24
|
-
const withoutLeadingSlash =
|
|
25
|
-
if (
|
|
15
|
+
const withoutLeadingSlash = target.slice(1);
|
|
16
|
+
if (target.endsWith('/')) {
|
|
26
17
|
return [`${withoutLeadingSlash}index.html`];
|
|
27
18
|
}
|
|
28
19
|
|
|
29
20
|
return [`${withoutLeadingSlash}/index.html`, `${withoutLeadingSlash}.html`];
|
|
30
21
|
}
|
|
31
22
|
|
|
32
|
-
function resolveTargetToAssetTarget(target: string): string | null {
|
|
33
|
-
const normalizedTarget = normalizeInternalTarget(target);
|
|
34
|
-
if (normalizedTarget === '/' || normalizedTarget.endsWith('.html')) {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
return path.posix.extname(normalizedTarget) ? normalizedTarget : null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function collectOutgoingHtmlAssetPaths(
|
|
41
|
-
analysis: HtmlOutputAnalysis,
|
|
42
|
-
knownHtmlAssetPaths: Set<string>,
|
|
43
|
-
): string[] {
|
|
44
|
-
const htmlAssetPaths = new Set<string>();
|
|
45
|
-
|
|
46
|
-
for (const target of analysis.outgoingTargets) {
|
|
47
|
-
for (const candidate of toCandidateHtmlAssetPaths(target)) {
|
|
48
|
-
if (knownHtmlAssetPaths.has(candidate)) {
|
|
49
|
-
htmlAssetPaths.add(candidate);
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return [...htmlAssetPaths].sort();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
23
|
interface CollectReachableOptions {
|
|
59
24
|
htmlAnalysisByPath: Map<string, HtmlOutputAnalysis>;
|
|
60
25
|
knownAssetTargets?: Set<string>;
|
|
@@ -75,7 +40,6 @@ export function collectReachableSiteAssets({
|
|
|
75
40
|
throw new Error(`Pagefind reachability root not found: ${rootPath}`);
|
|
76
41
|
}
|
|
77
42
|
|
|
78
|
-
const knownHtmlAssetPaths = new Set(htmlAnalysisByPath.keys());
|
|
79
43
|
const reachableHtmlPaths = new Set<string>();
|
|
80
44
|
const reachableAssetTargets = new Set<string>();
|
|
81
45
|
const pending: string[] = [rootPath];
|
|
@@ -89,19 +53,19 @@ export function collectReachableSiteAssets({
|
|
|
89
53
|
|
|
90
54
|
const analysis = htmlAnalysisByPath.get(currentPath)!;
|
|
91
55
|
|
|
92
|
-
for (const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
56
|
+
for (const outgoingTarget of analysis.outgoingTargets) {
|
|
57
|
+
const target = normalizeOutputPath(outgoingTarget);
|
|
58
|
+
if (!target.endsWith('.html') && path.posix.extname(target)) {
|
|
59
|
+
if (knownAssetTargets.has(target)) {
|
|
60
|
+
reachableAssetTargets.add(target);
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
98
63
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
reachableAssetTargets.add(assetTarget);
|
|
64
|
+
const targetPath = toCandidateHtmlAssetPaths(target).find(candidate =>
|
|
65
|
+
htmlAnalysisByPath.has(candidate),
|
|
66
|
+
);
|
|
67
|
+
if (targetPath && !reachableHtmlPaths.has(targetPath)) {
|
|
68
|
+
pending.push(targetPath);
|
|
105
69
|
}
|
|
106
70
|
}
|
|
107
71
|
}
|
package/build/site-variables.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { SiteEnv } from './config-files';
|
|
|
4
4
|
import { loadSiteConfig } from './config-loader';
|
|
5
5
|
import { compile as compileJsonSchema, doValidation } from './json-schema';
|
|
6
6
|
import { getProjectDir } from './utils/paths';
|
|
7
|
+
import { isEmojiSymbol } from './utils/symbol';
|
|
7
8
|
import type { PlainTextLanguage, SiteVariables } from './types';
|
|
8
9
|
import siteSchema from '../schema/site.schema.json' with { type: 'json' };
|
|
9
10
|
import timezones from '../src/timezone/timezones.json' with { type: 'json' };
|
|
@@ -131,10 +132,7 @@ function getSiteVariables(env: SiteEnv): SiteVariables {
|
|
|
131
132
|
},
|
|
132
133
|
} as SiteVariables;
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
if (variables.symbol && !variables.faviconSymbol) {
|
|
136
|
-
variables.faviconSymbol = variables.symbol;
|
|
137
|
-
}
|
|
135
|
+
resolveFaviconSymbol(variables, fileName);
|
|
138
136
|
|
|
139
137
|
// Derive faviconColor from themeColor if not explicitly set
|
|
140
138
|
if (!variables.faviconColor) {
|
|
@@ -157,6 +155,28 @@ function getSiteVariables(env: SiteEnv): SiteVariables {
|
|
|
157
155
|
return variables;
|
|
158
156
|
}
|
|
159
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Derive faviconSymbol from a text symbol. Generated favicons are drawn with a
|
|
160
|
+
* text font that has no emoji, so an emoji symbol needs a text faviconSymbol.
|
|
161
|
+
*/
|
|
162
|
+
export function resolveFaviconSymbol(
|
|
163
|
+
variables: SiteVariables,
|
|
164
|
+
fileName: string,
|
|
165
|
+
): void {
|
|
166
|
+
if (!variables.symbol || variables.faviconSymbol) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!isEmojiSymbol(variables.symbol)) {
|
|
170
|
+
variables.faviconSymbol = variables.symbol;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (variables.features.favicon !== false && !variables.favicon) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`${fileName}: faviconSymbol is required when symbol is an emoji and favicons are generated; set faviconSymbol to 1 to 5 uppercase characters, digits, hyphens, or spaces`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
export function getDevSiteVariables(): SiteVariables {
|
|
161
181
|
return getSiteVariables('dev');
|
|
162
182
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { encodePublicAssetPath } from './custom-fonts';
|
|
2
|
+
import { isEmojiSymbol } from './utils/symbol';
|
|
2
3
|
import timezones from '../src/timezone/timezones.json' with { type: 'json' };
|
|
3
4
|
import type { SiteVariables } from './types';
|
|
4
5
|
|
|
@@ -26,6 +27,7 @@ interface TemplateGlobals {
|
|
|
26
27
|
cx: (obj: Record<string, unknown>) => string;
|
|
27
28
|
encodeAuthoredUrl: (value: string) => string;
|
|
28
29
|
encodePublicAssetPath: (value: string) => string;
|
|
30
|
+
isEmojiSymbol: (value: string | null | undefined) => boolean;
|
|
29
31
|
renderTimeZoneChooser: () => string;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -56,6 +58,7 @@ export default function createTemplateGlobals(
|
|
|
56
58
|
cx: classNames,
|
|
57
59
|
encodeAuthoredUrl,
|
|
58
60
|
encodePublicAssetPath,
|
|
61
|
+
isEmojiSymbol,
|
|
59
62
|
renderTimeZoneChooser,
|
|
60
63
|
};
|
|
61
64
|
}
|
package/build/utils/render.ts
CHANGED
|
@@ -426,7 +426,7 @@ export function renderCopiedContentAsset({
|
|
|
426
426
|
const rawSource = fs.readFileSync(filePath, 'utf-8');
|
|
427
427
|
const templated = applySourceTemplate(rawSource, siteVariables, filePath);
|
|
428
428
|
|
|
429
|
-
if (filePath.
|
|
429
|
+
if (path.extname(filePath).toLowerCase() === '.java') {
|
|
430
430
|
const pageDirPath = toPosix(
|
|
431
431
|
path.relative(contentDir, path.dirname(filePath)),
|
|
432
432
|
);
|
|
@@ -568,7 +568,7 @@ function renderPlainTextContent(
|
|
|
568
568
|
} {
|
|
569
569
|
const applyBasePath = createApplyBasePath(siteVariables);
|
|
570
570
|
|
|
571
|
-
const ext = path.extname(filePath);
|
|
571
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
572
572
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
573
573
|
const { pageVariables: rawPageVariables, content } =
|
|
574
574
|
parseFrontMatterAndContent(raw, ext);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// One emoji: a flag, keycap, subdivision flag, or pictograph with optional
|
|
2
|
+
// skin tone and ZWJ joins. Written for the `u` flag so the site schema (Ajv)
|
|
3
|
+
// can share it; `\p{RGI_Emoji}` needs the `v` flag.
|
|
4
|
+
const EMOJI = String.raw`\p{RI}\p{RI}|[#*0-9]\uFE0F\u20E3|\u{1F3F4}[\u{E0061}-\u{E007A}]+\u{E007F}|(?!\p{RI})(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)\p{Emoji_Modifier}?(?:\u200D(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F?)\p{Emoji_Modifier}?)*`;
|
|
5
|
+
|
|
6
|
+
/** Must match `faviconSymbol` in `schema/site.schema.json` */
|
|
7
|
+
export const TEXT_SYMBOL_PATTERN = '^[A-Z0-9\\- ]{1,5}$';
|
|
8
|
+
|
|
9
|
+
/** Must match `symbol` in `schema/site.schema.json` */
|
|
10
|
+
export const SYMBOL_PATTERN = `^(?:[A-Z0-9\\- ]{1,5}|${EMOJI})$`;
|
|
11
|
+
|
|
12
|
+
const SYMBOL_RE = new RegExp(SYMBOL_PATTERN, 'u');
|
|
13
|
+
const EMOJI_RE = new RegExp(`^(?:${EMOJI})$`, 'u');
|
|
14
|
+
|
|
15
|
+
export function isValidSymbol(value: string): boolean {
|
|
16
|
+
return SYMBOL_RE.test(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isEmojiSymbol(value: string | null | undefined): boolean {
|
|
20
|
+
return value != null && EMOJI_RE.test(value);
|
|
21
|
+
}
|
|
@@ -34,18 +34,13 @@ const MIN_ARRAY_CELL_WIDTH = 48;
|
|
|
34
34
|
|
|
35
35
|
/** Information collected about a heap object across all trace steps. */
|
|
36
36
|
interface ObjectInfo {
|
|
37
|
-
id: string;
|
|
38
37
|
type: string;
|
|
39
38
|
firstStep: number;
|
|
40
39
|
lastStep: number;
|
|
41
|
-
/** Set of object IDs this object ever references (across all steps). */
|
|
42
|
-
references: Set<string>;
|
|
43
40
|
/** Step indices where this object is actually present. */
|
|
44
41
|
activeSteps: Set<number>;
|
|
45
42
|
/** Per-field reference targets (field name -> set of referenced object IDs). */
|
|
46
43
|
fieldRefs: Map<string, Set<string>>;
|
|
47
|
-
/** Max number of fields seen on this object (for field objects). */
|
|
48
|
-
maxFields: number;
|
|
49
44
|
/** Max number of elements seen on this object (for arrays). */
|
|
50
45
|
maxElements: number;
|
|
51
46
|
/** Scalar value (for heap objects represented by a single value). */
|
|
@@ -70,7 +65,7 @@ interface TreeNode {
|
|
|
70
65
|
|
|
71
66
|
/**
|
|
72
67
|
* Walk every step, collect every heap object's type, lifetime (first/last step),
|
|
73
|
-
*
|
|
68
|
+
* reference targets by field, display widths, and whether it's an array or string.
|
|
74
69
|
*/
|
|
75
70
|
export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
|
|
76
71
|
const objects = new Map<string, ObjectInfo>();
|
|
@@ -81,14 +76,11 @@ export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
|
|
|
81
76
|
let info = objects.get(id);
|
|
82
77
|
if (!info) {
|
|
83
78
|
info = {
|
|
84
|
-
id,
|
|
85
79
|
type: obj.type,
|
|
86
80
|
firstStep: stepIndex,
|
|
87
81
|
lastStep: stepIndex,
|
|
88
|
-
references: new Set(),
|
|
89
82
|
activeSteps: new Set(),
|
|
90
83
|
fieldRefs: new Map(),
|
|
91
|
-
maxFields: 0,
|
|
92
84
|
maxElements: 0,
|
|
93
85
|
value: null,
|
|
94
86
|
hasValue: false,
|
|
@@ -115,18 +107,14 @@ export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
|
|
|
115
107
|
|
|
116
108
|
for (const elem of obj.elements) {
|
|
117
109
|
if (elem.type === 'ref') {
|
|
118
|
-
info.references.add(elem.id);
|
|
119
110
|
elemRefs.add(elem.id);
|
|
120
111
|
}
|
|
121
112
|
}
|
|
122
113
|
} else if ('fields' in obj) {
|
|
123
|
-
const
|
|
124
|
-
info.maxFields = Math.max(info.maxFields, fieldEntries.length);
|
|
125
|
-
for (const [fieldName, val] of fieldEntries) {
|
|
114
|
+
for (const [fieldName, val] of Object.entries(obj.fields)) {
|
|
126
115
|
info.fieldNames.add(fieldName);
|
|
127
116
|
|
|
128
117
|
if (val.type === 'ref') {
|
|
129
|
-
info.references.add(val.id);
|
|
130
118
|
if (!info.fieldRefs.has(fieldName)) {
|
|
131
119
|
info.fieldRefs.set(fieldName, new Set());
|
|
132
120
|
}
|
package/package.json
CHANGED
package/schema/site.schema.json
CHANGED
|
@@ -31,14 +31,17 @@
|
|
|
31
31
|
},
|
|
32
32
|
"title": { "type": "string" },
|
|
33
33
|
"banner": { "type": "string" },
|
|
34
|
-
"symbol": {
|
|
34
|
+
"symbol": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"pattern": "^(?:[A-Z0-9\\- ]{1,5}|\\p{RI}\\p{RI}|[#*0-9]\\uFE0F\\u20E3|\\u{1F3F4}[\\u{E0061}-\\u{E007A}]+\\u{E007F}|(?!\\p{RI})(?:\\p{Emoji_Presentation}|\\p{Extended_Pictographic}\\uFE0F)\\p{Emoji_Modifier}?(?:\\u200D(?:\\p{Emoji_Presentation}|\\p{Extended_Pictographic}\\uFE0F?)\\p{Emoji_Modifier}?)*)$"
|
|
37
|
+
},
|
|
35
38
|
"titlePostfix": { "type": "string" },
|
|
36
39
|
"themeColor": { "type": "string" },
|
|
37
40
|
"tintHue": {
|
|
38
41
|
"type": "integer",
|
|
39
42
|
"minimum": 0,
|
|
40
43
|
"maximum": 360,
|
|
41
|
-
"default":
|
|
44
|
+
"default": 190
|
|
42
45
|
},
|
|
43
46
|
"tintAmount": {
|
|
44
47
|
"type": "integer",
|
package/src/_content.scss
CHANGED
package/src/_icons.scss
CHANGED
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
@include define-icon(
|
|
66
66
|
'heading-present',
|
|
67
67
|
24,
|
|
68
|
-
"<rect x='
|
|
68
|
+
"<rect x='4' y='5' width='16' height='11' rx='1'/><path d='M12 16v4M9 20h6'/>"
|
|
69
69
|
);
|
|
70
70
|
@include define-icon(
|
|
71
71
|
'check',
|
package/src/_layout.scss
CHANGED
package/src/header/_index.scss
CHANGED
|
@@ -184,6 +184,17 @@ header summary .logo {
|
|
|
184
184
|
background: var(--theme-color);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// A single emoji is the logo itself: no badge, sized like an image logo
|
|
188
|
+
header summary .logo-emoji {
|
|
189
|
+
padding: 0;
|
|
190
|
+
font-family:
|
|
191
|
+
'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
|
192
|
+
font-size: 22px;
|
|
193
|
+
font-weight: normal;
|
|
194
|
+
line-height: 24px;
|
|
195
|
+
background: none;
|
|
196
|
+
}
|
|
197
|
+
|
|
187
198
|
header summary .logo-image {
|
|
188
199
|
flex: 0 0 auto;
|
|
189
200
|
width: auto;
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,8 @@ const PERSISTENT_COMPONENTS: Record<
|
|
|
25
25
|
let startTime = -1;
|
|
26
26
|
|
|
27
27
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
28
|
+
const initialHref = window.location.href;
|
|
29
|
+
const initialHash = window.location.hash;
|
|
28
30
|
startTime = window.performance.now();
|
|
29
31
|
|
|
30
32
|
const failed: Record<string, string> = {};
|
|
@@ -54,8 +56,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
54
56
|
// fragment scroll. Do it ourselves once per-page components have
|
|
55
57
|
// finished mutating the DOM. On a fresh cold load the browser already
|
|
56
58
|
// scrolled, so this is a harmless re-align.
|
|
57
|
-
if (window.location.
|
|
58
|
-
getHashTarget(window.document,
|
|
59
|
+
if (initialHash && window.location.href === initialHref) {
|
|
60
|
+
getHashTarget(window.document, initialHash)?.scrollIntoView();
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
for (const [name, reason] of Object.entries(failed)) {
|
package/src/navigate/_index.scss
CHANGED
|
@@ -7,6 +7,11 @@ $movement-easing: cubic-bezier(0.2, 0, 0, 1);
|
|
|
7
7
|
--page-transition-movement-easing: #{$movement-easing};
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
html.navigation-loading,
|
|
11
|
+
html.navigation-loading * {
|
|
12
|
+
cursor: progress !important;
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
@keyframes page-fade-out {
|
|
11
16
|
to {
|
|
12
17
|
opacity: 0;
|
package/src/navigate/runtime.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { swapHeaderTitle } from '../header';
|
|
|
9
9
|
|
|
10
10
|
export const NAVIGATION_EVENT = 'tada:navigation';
|
|
11
11
|
|
|
12
|
+
const LOADING_CURSOR_DELAY = 400;
|
|
13
|
+
|
|
12
14
|
let currentAbortController: AbortController | null = null;
|
|
13
15
|
let historyIndex = 0;
|
|
14
16
|
let currentPath = '';
|
|
@@ -235,15 +237,28 @@ export async function navigateToUrl(
|
|
|
235
237
|
|
|
236
238
|
const header = document.querySelector('header');
|
|
237
239
|
header?.classList.add('loading');
|
|
240
|
+
const loadingCursorTimeout = window.setTimeout(() => {
|
|
241
|
+
if (isActiveNavigation(controller)) {
|
|
242
|
+
document.documentElement.classList.add('navigation-loading');
|
|
243
|
+
}
|
|
244
|
+
}, LOADING_CURSOR_DELAY);
|
|
245
|
+
const stopLoading = (): void => {
|
|
246
|
+
window.clearTimeout(loadingCursorTimeout);
|
|
247
|
+
if (!isActiveNavigation(controller)) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
header?.classList.remove('loading');
|
|
251
|
+
document.documentElement.classList.remove('navigation-loading');
|
|
252
|
+
};
|
|
238
253
|
|
|
239
254
|
let response: Response;
|
|
240
255
|
try {
|
|
241
256
|
response = await globals.fetch(url, { signal: controller.signal });
|
|
242
257
|
} catch (err: unknown) {
|
|
258
|
+
stopLoading();
|
|
243
259
|
if (!isActiveNavigation(controller)) {
|
|
244
260
|
return;
|
|
245
261
|
}
|
|
246
|
-
header?.classList.remove('loading');
|
|
247
262
|
if (isAbortError(err)) {
|
|
248
263
|
return;
|
|
249
264
|
}
|
|
@@ -252,11 +267,12 @@ export async function navigateToUrl(
|
|
|
252
267
|
}
|
|
253
268
|
|
|
254
269
|
if (!isActiveNavigation(controller)) {
|
|
270
|
+
stopLoading();
|
|
255
271
|
return;
|
|
256
272
|
}
|
|
257
273
|
|
|
258
274
|
if (!response.ok) {
|
|
259
|
-
|
|
275
|
+
stopLoading();
|
|
260
276
|
globals.setLocationHref(window, url);
|
|
261
277
|
return;
|
|
262
278
|
}
|
|
@@ -265,10 +281,10 @@ export async function navigateToUrl(
|
|
|
265
281
|
try {
|
|
266
282
|
html = await response.text();
|
|
267
283
|
} catch (err: unknown) {
|
|
284
|
+
stopLoading();
|
|
268
285
|
if (!isActiveNavigation(controller)) {
|
|
269
286
|
return;
|
|
270
287
|
}
|
|
271
|
-
header?.classList.remove('loading');
|
|
272
288
|
if (isAbortError(err)) {
|
|
273
289
|
return;
|
|
274
290
|
}
|
|
@@ -277,10 +293,11 @@ export async function navigateToUrl(
|
|
|
277
293
|
}
|
|
278
294
|
|
|
279
295
|
if (!isActiveNavigation(controller)) {
|
|
296
|
+
stopLoading();
|
|
280
297
|
return;
|
|
281
298
|
}
|
|
282
299
|
|
|
283
|
-
|
|
300
|
+
stopLoading();
|
|
284
301
|
|
|
285
302
|
const DOMParserCtor = (window as unknown as { DOMParser: typeof DOMParser })
|
|
286
303
|
.DOMParser;
|
package/src/question/_index.scss
CHANGED
package/src/question/index.ts
CHANGED
|
@@ -62,12 +62,19 @@ export default (window: Window) => {
|
|
|
62
62
|
const bodies =
|
|
63
63
|
window.document.querySelectorAll<HTMLElement>('.question-a-body');
|
|
64
64
|
bodies.forEach(el => {
|
|
65
|
+
const content = el.querySelector<HTMLElement>('.question-a-content');
|
|
66
|
+
if (content) {
|
|
67
|
+
content.inert = !el.hasAttribute('data-revealed');
|
|
68
|
+
}
|
|
65
69
|
el.setAttribute('role', 'button');
|
|
66
70
|
el.setAttribute('tabindex', '0');
|
|
67
71
|
el.setAttribute('aria-label', 'Click to reveal answer');
|
|
68
72
|
|
|
69
73
|
const reveal = () => {
|
|
70
74
|
el.setAttribute('data-revealed', '');
|
|
75
|
+
if (content) {
|
|
76
|
+
content.inert = false;
|
|
77
|
+
}
|
|
71
78
|
el.removeAttribute('role');
|
|
72
79
|
el.removeAttribute('tabindex');
|
|
73
80
|
el.removeAttribute('aria-label');
|
package/src/search/index.ts
CHANGED
|
@@ -94,25 +94,7 @@ async function doSearch(query: string, window: Window): Promise<SearchState> {
|
|
|
94
94
|
return { totalResults: grouped.length, results: grouped };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function
|
|
98
|
-
resultsContainer: HTMLElement,
|
|
99
|
-
focusedEl: HTMLElement | null,
|
|
100
|
-
) {
|
|
101
|
-
const options = Array.from(
|
|
102
|
-
resultsContainer.querySelectorAll('[role="option"]'),
|
|
103
|
-
) as HTMLElement[];
|
|
104
|
-
options.forEach(opt => {
|
|
105
|
-
const selected = focusedEl !== null && opt.contains(focusedEl);
|
|
106
|
-
opt.setAttribute('aria-selected', selected ? 'true' : 'false');
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function render(
|
|
111
|
-
input: HTMLInputElement,
|
|
112
|
-
resultsContainer: HTMLElement,
|
|
113
|
-
state: State,
|
|
114
|
-
loading = false,
|
|
115
|
-
) {
|
|
97
|
+
function render(resultsContainer: HTMLElement, state: State, loading = false) {
|
|
116
98
|
const doc = resultsContainer.ownerDocument;
|
|
117
99
|
|
|
118
100
|
if (state.showResults) {
|
|
@@ -129,16 +111,10 @@ function render(
|
|
|
129
111
|
}
|
|
130
112
|
|
|
131
113
|
const ol = doc.createElement('ol');
|
|
132
|
-
ol.id = `${input.name}-results`;
|
|
133
|
-
ol.role = 'listbox';
|
|
134
|
-
ol.tabIndex = -1;
|
|
135
114
|
ol.setAttribute('aria-label', 'Search results');
|
|
136
115
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
state.results.forEach((result, i) => {
|
|
116
|
+
state.results.forEach(result => {
|
|
140
117
|
const a = doc.createElement('a');
|
|
141
|
-
a.id = `result-${i}`;
|
|
142
118
|
a.className = 'result';
|
|
143
119
|
a.href = result.url;
|
|
144
120
|
const isGeneratedPage = Boolean(result.template);
|
|
@@ -148,7 +124,6 @@ function render(
|
|
|
148
124
|
a.tabIndex = 0;
|
|
149
125
|
|
|
150
126
|
const titleEl = doc.createElement('div');
|
|
151
|
-
titleEl.id = `title-${i}`;
|
|
152
127
|
titleEl.className = 'title';
|
|
153
128
|
if (result.template === 'code') {
|
|
154
129
|
titleEl.classList.add('code-page');
|
|
@@ -166,12 +141,6 @@ function render(
|
|
|
166
141
|
a.appendChild(excerpt);
|
|
167
142
|
|
|
168
143
|
const li = doc.createElement('li');
|
|
169
|
-
li.id = `option-${i}`;
|
|
170
|
-
li.setAttribute('role', 'option');
|
|
171
|
-
li.setAttribute('aria-selected', 'false');
|
|
172
|
-
li.setAttribute('aria-setsize', String(totalVisible));
|
|
173
|
-
li.setAttribute('aria-posinset', String(i + 1));
|
|
174
|
-
li.setAttribute('aria-labelledby', `title-${i}`);
|
|
175
144
|
li.appendChild(a);
|
|
176
145
|
|
|
177
146
|
const subsToShow = result.subResults;
|
|
@@ -265,9 +234,6 @@ function render(
|
|
|
265
234
|
resultsContainer.setAttribute('aria-hidden', 'true');
|
|
266
235
|
resultsContainer.setAttribute('inert', '');
|
|
267
236
|
}
|
|
268
|
-
|
|
269
|
-
input.setAttribute('aria-expanded', String(state.showResults));
|
|
270
|
-
input.setAttribute('aria-controls', ol.id);
|
|
271
237
|
}
|
|
272
238
|
|
|
273
239
|
export default (window: Window) => {
|
|
@@ -335,7 +301,7 @@ export default (window: Window) => {
|
|
|
335
301
|
pagefindLoadGeneration += 1;
|
|
336
302
|
console.log(`failed to load Pagefind: ${err}`);
|
|
337
303
|
if (state.showResults) {
|
|
338
|
-
render(
|
|
304
|
+
render(resultsContainer, state, false);
|
|
339
305
|
}
|
|
340
306
|
}
|
|
341
307
|
return false;
|
|
@@ -362,7 +328,7 @@ export default (window: Window) => {
|
|
|
362
328
|
|
|
363
329
|
async function update(updateId: number) {
|
|
364
330
|
if (state.showResults) {
|
|
365
|
-
render(
|
|
331
|
+
render(resultsContainer, state, true);
|
|
366
332
|
}
|
|
367
333
|
const query = state.value;
|
|
368
334
|
if (!(await loadPagefind()) || updateId !== latestUpdateId) {
|
|
@@ -380,7 +346,7 @@ export default (window: Window) => {
|
|
|
380
346
|
if (!state.showResults) {
|
|
381
347
|
return;
|
|
382
348
|
}
|
|
383
|
-
render(
|
|
349
|
+
render(resultsContainer, state, false);
|
|
384
350
|
}
|
|
385
351
|
|
|
386
352
|
function queueUpdate() {
|
|
@@ -402,7 +368,7 @@ export default (window: Window) => {
|
|
|
402
368
|
.catch(err => {
|
|
403
369
|
console.log(`failed to load Pagefind: ${err}`);
|
|
404
370
|
if (state.showResults) {
|
|
405
|
-
render(
|
|
371
|
+
render(resultsContainer, state, false);
|
|
406
372
|
}
|
|
407
373
|
});
|
|
408
374
|
}
|
|
@@ -418,7 +384,7 @@ export default (window: Window) => {
|
|
|
418
384
|
const stalePagefind = pagefind;
|
|
419
385
|
invalidatePagefind();
|
|
420
386
|
if (state.showResults) {
|
|
421
|
-
render(
|
|
387
|
+
render(resultsContainer, state, true);
|
|
422
388
|
}
|
|
423
389
|
if (stalePagefind) {
|
|
424
390
|
try {
|
|
@@ -434,7 +400,7 @@ export default (window: Window) => {
|
|
|
434
400
|
reloadPagefindAfterPageUpdateRefresh().catch(err => {
|
|
435
401
|
console.log(`failed to reload Pagefind: ${err}`);
|
|
436
402
|
if (state.showResults) {
|
|
437
|
-
render(
|
|
403
|
+
render(resultsContainer, state, false);
|
|
438
404
|
}
|
|
439
405
|
});
|
|
440
406
|
}
|
|
@@ -446,7 +412,7 @@ export default (window: Window) => {
|
|
|
446
412
|
return;
|
|
447
413
|
}
|
|
448
414
|
state.showResults = false;
|
|
449
|
-
render(
|
|
415
|
+
render(resultsContainer, state);
|
|
450
416
|
}
|
|
451
417
|
|
|
452
418
|
function handleInput(e: Event) {
|
|
@@ -573,23 +539,11 @@ export default (window: Window) => {
|
|
|
573
539
|
}
|
|
574
540
|
} else if (e.key === 'Escape') {
|
|
575
541
|
e.preventDefault();
|
|
576
|
-
hide();
|
|
577
542
|
input!.focus();
|
|
543
|
+
hide();
|
|
578
544
|
}
|
|
579
545
|
}
|
|
580
546
|
|
|
581
|
-
function handleResultsFocusIn(e: FocusEvent) {
|
|
582
|
-
applyHighlight(resultsContainer, e.target as HTMLElement);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function handleResultsFocusOut(e: FocusEvent) {
|
|
586
|
-
const related = e.relatedTarget as HTMLElement | null;
|
|
587
|
-
if (related && (resultsContainer.contains(related) || related === input)) {
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
applyHighlight(resultsContainer, null);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
547
|
function handleWindowPointerDown(e: PointerEvent) {
|
|
594
548
|
const target = e.target as Node;
|
|
595
549
|
if (!input!.contains(target) && !resultsContainer.contains(target)) {
|
|
@@ -616,8 +570,6 @@ export default (window: Window) => {
|
|
|
616
570
|
resultsContainer.addEventListener('pointerdown', handlePointerDown);
|
|
617
571
|
resultsContainer.addEventListener('click', handleResultClick);
|
|
618
572
|
resultsContainer.addEventListener('keydown', handleResultsKeyDown);
|
|
619
|
-
resultsContainer.addEventListener('focusin', handleResultsFocusIn);
|
|
620
|
-
resultsContainer.addEventListener('focusout', handleResultsFocusOut);
|
|
621
573
|
window.addEventListener('pointerup', handleWindowPointerUp);
|
|
622
574
|
window.addEventListener('pointerdown', handleWindowPointerDown);
|
|
623
575
|
window.addEventListener('pointermove', handleWindowPointerMove);
|
|
@@ -637,8 +589,6 @@ export default (window: Window) => {
|
|
|
637
589
|
window.removeEventListener('pointermove', handleWindowPointerMove);
|
|
638
590
|
window.removeEventListener('pointerdown', handleWindowPointerDown);
|
|
639
591
|
window.removeEventListener('pointerup', handleWindowPointerUp);
|
|
640
|
-
resultsContainer.removeEventListener('focusout', handleResultsFocusOut);
|
|
641
|
-
resultsContainer.removeEventListener('focusin', handleResultsFocusIn);
|
|
642
592
|
resultsContainer.removeEventListener('keydown', handleResultsKeyDown);
|
|
643
593
|
resultsContainer.removeEventListener('click', handleResultClick);
|
|
644
594
|
resultsContainer.removeEventListener('pointerdown', handlePointerDown);
|
package/src/top/README.md
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
# The `top` component
|
|
2
2
|
|
|
3
|
-
Show a floating "back to top" button once the
|
|
4
|
-
and keep it visible until
|
|
3
|
+
Show a floating "back to top" button on long pages once the reader has scrolled
|
|
4
|
+
well down the page, and keep it visible until they are back above that point.
|
|
5
|
+
|
|
6
|
+
- The page must be at least 3 viewports tall; shorter pages never show it.
|
|
7
|
+
- It shows once the scroll position passes 1.5 viewports or 25% of the
|
|
8
|
+
scrollable distance, whichever is farther.
|
|
9
|
+
- Visibility is recalculated on scroll and on resize.
|
package/src/top/index.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { debounce, removeClass } from '../util';
|
|
2
2
|
import { globals } from '../globals';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
const
|
|
4
|
+
/** Only show the "Back to top" button on pages at least this many viewports tall */
|
|
5
|
+
const MIN_PAGE_SCREENS = 3;
|
|
6
|
+
|
|
7
|
+
/** Show the button once the user is this many viewports down the page... */
|
|
8
|
+
const SHOW_AFTER_SCREENS = 1.5;
|
|
9
|
+
|
|
10
|
+
/** ...or this fraction of the scrollable distance, whichever is farther */
|
|
11
|
+
const SHOW_AFTER_FRACTION = 0.25;
|
|
6
12
|
|
|
7
13
|
/** Debounce time (maximum amount of time to wait before updates) */
|
|
8
14
|
const LATENCY_MS = 50;
|
|
@@ -60,8 +66,22 @@ export default (window: Window) => {
|
|
|
60
66
|
isShowing = false;
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
function shouldShow(): boolean {
|
|
70
|
+
const viewportHeight = window.innerHeight;
|
|
71
|
+
const maxScroll =
|
|
72
|
+
window.document.documentElement.scrollHeight - viewportHeight;
|
|
73
|
+
if (maxScroll < (MIN_PAGE_SCREENS - 1) * viewportHeight) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const threshold = Math.max(
|
|
77
|
+
SHOW_AFTER_SCREENS * viewportHeight,
|
|
78
|
+
SHOW_AFTER_FRACTION * maxScroll,
|
|
79
|
+
);
|
|
80
|
+
return window.scrollY > threshold;
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
function updateVisibility() {
|
|
64
|
-
if (
|
|
84
|
+
if (shouldShow()) {
|
|
65
85
|
show(link);
|
|
66
86
|
} else {
|
|
67
87
|
hide(link);
|
|
@@ -70,9 +90,11 @@ export default (window: Window) => {
|
|
|
70
90
|
|
|
71
91
|
const debounced = debounce(window, updateVisibility, LATENCY_MS);
|
|
72
92
|
window.addEventListener('scroll', debounced, { passive: true });
|
|
93
|
+
window.addEventListener('resize', debounced, { passive: true });
|
|
73
94
|
updateVisibility();
|
|
74
95
|
|
|
75
96
|
return () => {
|
|
76
97
|
window.removeEventListener('scroll', debounced);
|
|
98
|
+
window.removeEventListener('resize', debounced);
|
|
77
99
|
};
|
|
78
100
|
};
|
package/templates/_top.html
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<link rel="apple-touch-icon" sizes="512x512" href="/favicon-512.png" />
|
|
24
24
|
<link rel="apple-touch-icon" sizes="256x256" href="/favicon-256.png" />
|
|
25
25
|
<link rel="manifest" href="/manifest.json" />
|
|
26
|
-
<meta name="apple-mobile-web-app-title" content="<%- site.
|
|
26
|
+
<meta name="apple-mobile-web-app-title" content="<%- site.title %>">
|
|
27
27
|
<meta name="mobile-web-app-capable" content="yes">
|
|
28
28
|
<% } %>
|
|
29
29
|
<% } %>
|
|
@@ -192,20 +192,17 @@
|
|
|
192
192
|
<div class="header-controls">
|
|
193
193
|
<div id="to-top-container" class="to-top-container"></div>
|
|
194
194
|
<% if (site.features.search) { %>
|
|
195
|
-
<div class="search-controls">
|
|
195
|
+
<div class="search-controls" role="search">
|
|
196
196
|
<input type="search" class="search quick-search"
|
|
197
197
|
autocomplete="off" name="quick-search"
|
|
198
198
|
placeholder="Search"
|
|
199
|
-
|
|
200
|
-
aria-haspopup="listbox"
|
|
201
|
-
aria-autocomplete="list"
|
|
202
|
-
aria-expanded="false"
|
|
199
|
+
aria-label="Search"
|
|
203
200
|
aria-controls="quick-search-results"
|
|
204
201
|
disabled />
|
|
205
202
|
</div>
|
|
206
203
|
<% } %>
|
|
207
204
|
</div>
|
|
208
|
-
<div class="results-container" role="region"
|
|
205
|
+
<div id="quick-search-results" class="results-container" role="region"
|
|
209
206
|
aria-label="Search results"
|
|
210
207
|
aria-live="polite" aria-hidden="true" inert>
|
|
211
208
|
<div class="results" style="display:none"></div>
|
|
@@ -219,7 +216,7 @@
|
|
|
219
216
|
<path class="menu-icon-line menu-icon-line-middle" d="M3 12h18" />
|
|
220
217
|
<path class="menu-icon-line menu-icon-line-bottom" d="M3 18h18" />
|
|
221
218
|
</svg>
|
|
222
|
-
<% if (site.logo) { %><img class="logo-image" src="/<%- encodePublicAssetPath(site.logo) %>" alt="" aria-hidden="true" /><% } else if (site.symbol) { %><span class="logo" aria-hidden="true"><%- site.symbol %></span><% } %>
|
|
219
|
+
<% if (site.logo) { %><img class="logo-image" src="/<%- encodePublicAssetPath(site.logo) %>" alt="" aria-hidden="true" /><% } else if (site.symbol) { %><span class="<%- isEmojiSymbol(site.symbol) ? 'logo logo-emoji' : 'logo' %>" aria-hidden="true"><%- site.symbol %></span><% } %>
|
|
223
220
|
<span class="header-title" aria-hidden="true">
|
|
224
221
|
<span class="header-title-reel">
|
|
225
222
|
<span class="site-title"><%- site.title %></span>
|