@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 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 (light & dark following system, floating header, styled lists)
11
- - Fast client-side navigation
12
- - Detects a new version of the current page and allows the user to reload
13
- - Clickable/linkable landmarks (headings, deflists, alert boxes)
14
- - Dynamic table of contents
15
- * Floats on the side of the screen when window is large enough
16
- * Renders headings, alert boxes, and `<hr>` elements
17
- * Highlights the heading currently being viewed
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
- - Interactive execution traces via `renderTrace()`
26
- * Supports Java and Python source files
27
- * Renders step-by-step source highlighting, output, and memory diagrams
28
- - Slides mode: separate content by `---` and present sections as full-screen slides
29
- * Click icon near slide titles to start presenting at that slide
30
- * Right-click in presentation mode to annotate
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
- - Custom header logo from an image in `public/`
49
- - Automatically generated favicon or custom ICO from `public/`
50
- * Text, color, font and font weight taken from config file
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
- - **Theme color**: HSL color, e.g. `hsl(195 70% 40%)`
112
- - **Background tint hue**: hue (0-360) for background/foreground tinting (defaults to `33`)
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 `33`) |
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 > 0 && val < 65536) {
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: 'Introduction to Computer Science',
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: 'CS 0',
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: 'hsl(195 70% 40%)',
89
+ defaultValue: 'darkturquoise',
89
90
  validate: validateColor,
90
91
  },
91
92
  tintHue: {
92
93
  prompt: 'Background tint hue (0-360)',
93
- defaultValue: '33',
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: { search: true, favicon: false, footer: true, pickers: true },
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: { search: true, favicon: true, footer: true, pickers: true },
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.length > 5) {
12
- return 'Symbol must be 5 characters or fewer';
12
+ if (isValidSymbol(value)) {
13
+ return null;
13
14
  }
14
- if (!/^[A-Z0-9\- ]{1,5}$/.test(value)) {
15
- return 'Symbol must contain only uppercase letters, digits, hyphens, and spaces';
15
+ if (value.length > 5) {
16
+ return 'Symbol must be 5 characters or fewer, or a single emoji';
16
17
  }
17
- return null;
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 ?? 33;
54
+ const tintHue = siteVariables.tintHue ?? 190;
55
55
  const tintAmount = siteVariables.tintAmount ?? 100;
56
56
 
57
57
  const linkHue = formatCssNumber(deriveLinkHue(tintHue));
@@ -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
  }
@@ -2,59 +2,24 @@ import path from 'path';
2
2
  import { normalizeOutputPath } from './util';
3
3
  import type { HtmlOutputAnalysis } from './types';
4
4
 
5
- function normalizeInternalTarget(target: string): string {
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
- const normalizedTarget = normalizeInternalTarget(target);
11
-
12
- if (normalizedTarget === '/') {
7
+ if (target === '/') {
13
8
  return ['index.html'];
14
9
  }
15
10
 
16
- if (normalizedTarget.endsWith('.html')) {
17
- return [normalizedTarget.slice(1)];
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 = normalizedTarget.slice(1);
25
- if (normalizedTarget.endsWith('/')) {
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 targetPath of collectOutgoingHtmlAssetPaths(
93
- analysis,
94
- knownHtmlAssetPaths,
95
- )) {
96
- if (!reachableHtmlPaths.has(targetPath)) {
97
- pending.push(targetPath);
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
- for (const target of analysis.outgoingTargets) {
102
- const assetTarget = resolveTargetToAssetTarget(target);
103
- if (assetTarget && knownAssetTargets.has(assetTarget)) {
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
  }
@@ -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
- // Derive faviconSymbol from symbol if not explicitly set
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
  }
@@ -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.endsWith('.java')) {
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
- * all references it ever makes, max fields seen, whether it's an array or string.
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 fieldEntries = Object.entries(obj.fields);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.18.5",
3
+ "version": "1.19.1",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
@@ -31,14 +31,17 @@
31
31
  },
32
32
  "title": { "type": "string" },
33
33
  "banner": { "type": "string" },
34
- "symbol": { "type": "string", "pattern": "^[A-Z0-9\\- ]{1,5}$" },
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": 33
44
+ "default": 190
42
45
  },
43
46
  "tintAmount": {
44
47
  "type": "integer",
package/src/_content.scss CHANGED
@@ -184,7 +184,7 @@ button.icon-button:focus {
184
184
 
185
185
  ol.styled-list,
186
186
  ul.styled-list {
187
- font-weight: 700;
187
+ font-weight: 600;
188
188
  color: var(--theme-color-text);
189
189
  }
190
190
 
package/src/_icons.scss CHANGED
@@ -65,7 +65,7 @@
65
65
  @include define-icon(
66
66
  'heading-present',
67
67
  24,
68
- "<rect x='3.25' y='5.25' width='17.5' height='13.5' rx='2.25'/><path d='M10.25 9.25v5.5L14.75 12z' fill='black'/>"
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
@@ -220,6 +220,7 @@ body.literate main table {
220
220
  footer {
221
221
  @include user-select(none);
222
222
 
223
+ font-size: var(--font-size-smaller);
223
224
  color: var(--fg2-color);
224
225
  text-align: center;
225
226
 
@@ -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.hash) {
58
- getHashTarget(window.document, window.location.hash)?.scrollIntoView();
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)) {
@@ -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;
@@ -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
- header?.classList.remove('loading');
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
- header?.classList.remove('loading');
300
+ stopLoading();
284
301
 
285
302
  const DOMParserCtor = (window as unknown as { DOMParser: typeof DOMParser })
286
303
  .DOMParser;
@@ -21,7 +21,7 @@
21
21
  .question-a-label {
22
22
  flex: 0 0 var(--question-label-width);
23
23
  margin: 0;
24
- font-weight: 700;
24
+ font-weight: 600;
25
25
  color: var(--theme-color-text);
26
26
 
27
27
  @include user-select(none);
@@ -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');
@@ -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 applyHighlight(
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
- const totalVisible = state.results.length;
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(input!, resultsContainer, state, false);
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(input!, resultsContainer, state, true);
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(input!, resultsContainer, state, false);
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(input!, resultsContainer, state, false);
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(input!, resultsContainer, state, true);
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(input!, resultsContainer, state, false);
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(input!, resultsContainer, state);
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 page is scrolled more than 250px,
4
- and keep it visible until the page is back near the top.
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
- /** Show the "Back to top" button once the user is past this scroll position */
5
- const SHOW_THRESHOLD_PX = 250;
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 (window.scrollY > SHOW_THRESHOLD_PX) {
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
  };
@@ -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.symbol %>">
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
- role="combobox"
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>