@abreen/tada 1.18.0 → 1.18.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
@@ -383,7 +383,7 @@ list of variables parsed using the [`front-matter`][front-matter] library).
383
383
  | `description` | Meta description for the page |
384
384
  | `toc` | Set to `true` to show a table of contents |
385
385
  | `slides` | Set to `true` on Markdown pages to treat top-level `---` as slide separators and add presentation controls |
386
- | `parent` & `parentLabel` | URL and label for a breadcrumb link displayed above the title |
386
+ | `breadcrumbs` | Ordered ancestor links with `label` and `url`; the page title completes the trail |
387
387
  | `published` | Year, month, and day of publishing (e.g, `2025-09-09`) |
388
388
 
389
389
  You may also add arbitrary fields in a page's front matter, and access them
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z"/></svg>
@@ -48,10 +48,13 @@ export const MATERIAL_SYMBOLS = Object.freeze({
48
48
  symbol: 'warning',
49
49
  opticalSize: 20,
50
50
  }),
51
- parent: Object.freeze({
52
- cssVariable: '--icon-parent',
53
- symbol: 'south_east',
54
- opticalSize: 20,
51
+ breadcrumbSeparator: Object.freeze({
52
+ cssVariable: '--icon-breadcrumb-separator',
53
+ symbol: 'chevron_right',
54
+ opticalSize: 24,
55
+ // The authored selection differs from the shared weight/grade defaults.
56
+ weight: 400,
57
+ grade: 0,
55
58
  }),
56
59
  search: Object.freeze({
57
60
  cssVariable: '--icon-search',
package/build/types.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { BundledLanguage } from 'shiki';
2
2
  import type { TraceCache } from './build-types';
3
3
 
4
+ /** An explicitly authored ancestor in a page's breadcrumb trail. */
5
+ export interface BreadcrumbEntry {
6
+ label: string;
7
+ url: string;
8
+ }
9
+
4
10
  export type PlainTextLanguage = 'text' | 'txt' | 'plain';
5
11
 
6
12
  export interface SerifFontTuning {
@@ -15,8 +15,8 @@ import {
15
15
  getSupportedConfigFileNamesText,
16
16
  } from '../config-files';
17
17
  import {
18
- resolveParentLinkTarget,
19
- validateParentLink,
18
+ resolveBreadcrumbLinkTarget,
19
+ validateBreadcrumbs,
20
20
  } from '../validate-config-links';
21
21
  import {
22
22
  extractJavaMethodToc,
@@ -66,8 +66,8 @@ export { stripHtmlComments } from './html-comments';
66
66
 
67
67
  const REQUIRED_FRONT_MATTER_FIELDS = ['title'];
68
68
 
69
- // Page variables that renderers assign themselves. Front matter that sets one
70
- // would be overwritten or, for `template`, select a nonexistent template.
69
+ // Internal page variables and retired front matter keys. Internal values would
70
+ // be overwritten or, for `template`, select a nonexistent template.
71
71
  const RESERVED_FRONT_MATTER_KEYS: readonly string[] = [
72
72
  'template',
73
73
  'titleHtml',
@@ -77,6 +77,8 @@ const RESERVED_FRONT_MATTER_KEYS: readonly string[] = [
77
77
  'codeFilePath',
78
78
  'downloadName',
79
79
  'filePath',
80
+ 'parent',
81
+ 'parentLabel',
80
82
  ];
81
83
 
82
84
  function renderInlineField(
@@ -456,7 +458,7 @@ interface PreparePageVariablesInput {
456
458
  * Turns parsed front matter into page variables. Shared by every renderer
457
459
  * whose source has front matter, so each one applies the same rules:
458
460
  * Lodash processing of string values, inline Markdown for the title and
459
- * description, required fields, author lookup, and parent link validation.
461
+ * description, required fields, author lookup, and breadcrumb validation.
460
462
  */
461
463
  function preparePageVariables({
462
464
  rawPageVariables,
@@ -484,12 +486,29 @@ function preparePageVariables({
484
486
  subPath,
485
487
  isWatchMode,
486
488
  });
489
+ const templateString = (value: unknown): unknown =>
490
+ typeof value === 'string' ? compileTemplate(value)(siteOnlyParams) : value;
487
491
  const pageVariables: Record<string, unknown> = Object.fromEntries(
488
- Object.entries(rawPageVariables).map(([k, v]) => [
489
- k,
490
- typeof v === 'string' ? compileTemplate(v)(siteOnlyParams) : v,
492
+ Object.entries(rawPageVariables).map(([key, value]) => [
493
+ key,
494
+ templateString(value),
491
495
  ]),
492
496
  );
497
+ if (Array.isArray(pageVariables.breadcrumbs)) {
498
+ pageVariables.breadcrumbs = pageVariables.breadcrumbs.map(
499
+ (entry: unknown) => {
500
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
501
+ return entry;
502
+ }
503
+ const breadcrumb = entry as Record<string, unknown>;
504
+ return {
505
+ ...breadcrumb,
506
+ label: templateString(breadcrumb.label),
507
+ url: templateString(breadcrumb.url),
508
+ };
509
+ },
510
+ );
511
+ }
493
512
 
494
513
  // Render title and description as inline Markdown
495
514
  const frontMatterMd = createMarkdown(siteVariables, {
@@ -502,21 +521,21 @@ function preparePageVariables({
502
521
  validateFrontMatter(pageVariables, filePath);
503
522
  resolveAuthor(pageVariables, filePath, dependencyCollector);
504
523
 
505
- const parentError = validateParentLink(
506
- pageVariables.parent,
524
+ const breadcrumbs = pageVariables.breadcrumbs;
525
+ validateBreadcrumbs(
526
+ breadcrumbs,
507
527
  filePath,
508
528
  validInternalTargets,
509
529
  sourceUrlPath,
510
530
  );
511
- if (parentError) {
512
- throw new Error(parentError);
513
- }
514
- const resolvedParentTarget = resolveParentLinkTarget(
515
- pageVariables.parent,
516
- sourceUrlPath,
517
- );
518
- if (resolvedParentTarget) {
519
- dependencyCollector?.internalTargets?.add(resolvedParentTarget);
531
+ for (const entry of breadcrumbs ?? []) {
532
+ const resolvedTarget = resolveBreadcrumbLinkTarget(
533
+ entry.url,
534
+ sourceUrlPath,
535
+ );
536
+ if (resolvedTarget) {
537
+ dependencyCollector?.internalTargets?.add(resolvedTarget);
538
+ }
520
539
  }
521
540
 
522
541
  return pageVariables;
@@ -1,4 +1,5 @@
1
1
  import path from 'path';
2
+ import type { BreadcrumbEntry } from './types';
2
3
  import { encodeAuthoredUrl } from './template-globals';
3
4
  import { normalizeOutputPath } from './utils/paths';
4
5
 
@@ -208,47 +209,86 @@ function splitHref(href: string): { pathname: string; suffix: string } {
208
209
  return { pathname: match ? match[1] : href, suffix: match ? match[2] : '' };
209
210
  }
210
211
 
211
- export function validateParentLink(
212
- parent: unknown,
212
+ export function validateBreadcrumbs(
213
+ breadcrumbs: unknown,
213
214
  filePath: string,
214
215
  validTargets: ReadonlySet<string>,
215
216
  sourceUrlPath: string,
216
- ): string | null {
217
- if (typeof parent === 'string' && splitHref(parent).pathname === '') {
218
- return `${filePath}: broken parent link: "${parent}"`;
217
+ ): asserts breadcrumbs is BreadcrumbEntry[] | undefined {
218
+ if (breadcrumbs === undefined) {
219
+ return;
220
+ }
221
+ if (!Array.isArray(breadcrumbs)) {
222
+ throw new Error(`${filePath}: breadcrumbs must be a list`);
219
223
  }
224
+ breadcrumbs.forEach((entry: unknown, index) => {
225
+ const context = `${filePath}: breadcrumb entry ${index + 1}`;
226
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
227
+ throw new Error(`${context} must be an object with label and url fields`);
228
+ }
229
+ const { label, url } = entry as Record<string, unknown>;
230
+ for (const [field, value] of Object.entries({ label, url })) {
231
+ if (typeof value !== 'string' || !value.trim()) {
232
+ throw new Error(`${context}: ${field} must be a nonempty string`);
233
+ }
234
+ }
235
+ const error = validateBreadcrumbLink(
236
+ url as string,
237
+ filePath,
238
+ validTargets,
239
+ sourceUrlPath,
240
+ index + 1,
241
+ );
242
+ if (error) {
243
+ throw new Error(error);
244
+ }
245
+ });
246
+ }
220
247
 
221
- const resolvedTarget = resolveParentLinkTarget(parent, sourceUrlPath);
248
+ export function validateBreadcrumbLink(
249
+ url: string,
250
+ filePath: string,
251
+ validTargets: ReadonlySet<string>,
252
+ sourceUrlPath: string,
253
+ entryNumber = 1,
254
+ ): string | null {
255
+ const context = `${filePath}: breadcrumb entry ${entryNumber}`;
256
+ const resolvedTarget = resolveBreadcrumbLinkTarget(url, sourceUrlPath);
222
257
  if (!resolvedTarget) {
223
- return null;
258
+ return `${context}: broken breadcrumb link: "${url}" (must be an internal site link with a pathname)`;
224
259
  }
225
260
 
226
261
  const directoryError = validateDirectoryLink(
227
- parent as string,
262
+ url,
228
263
  resolvedTarget,
229
264
  validTargets,
230
- `${filePath}: parent link`,
265
+ context,
231
266
  );
232
267
  if (directoryError) {
233
268
  return directoryError;
234
269
  }
235
270
 
236
271
  if (!validTargets.has(resolvedTarget)) {
237
- return `${filePath}: broken parent link: "${parent}"`;
272
+ return `${context}: broken breadcrumb link: "${url}"`;
238
273
  }
239
274
 
240
275
  return null;
241
276
  }
242
277
 
243
- export function resolveParentLinkTarget(
244
- parent: unknown,
278
+ export function resolveBreadcrumbLinkTarget(
279
+ url: string,
245
280
  sourceUrlPath: string,
246
281
  ): string | null {
247
- if (!parent || typeof parent !== 'string') {
282
+ // Breadcrumbs always point into this site. Backslashes also have special
283
+ // URL semantics in browsers, so they cannot be used as path separators.
284
+ if (
285
+ /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) ||
286
+ url.startsWith('//') ||
287
+ url.includes('\\')
288
+ ) {
248
289
  return null;
249
290
  }
250
-
251
- const { pathname } = splitHref(parent);
291
+ const { pathname } = splitHref(url);
252
292
  if (!pathname) {
253
293
  return null;
254
294
  }
@@ -1,6 +1,9 @@
1
1
  ---
2
- parent: /labs/00/index.html
3
- parentLabel: Lab 0
2
+ breadcrumbs:
3
+ - label: Labs
4
+ url: /labs/index.html
5
+ - label: Lab 0
6
+ url: /labs/00/index.html
4
7
  title: Counting vowels
5
8
  author: alex
6
9
  description: An example of a literate Java program that takes predefined input.
@@ -1,6 +1,7 @@
1
1
  ---
2
- parent: /labs/index.html
3
- parentLabel: Labs
2
+ breadcrumbs:
3
+ - label: Labs
4
+ url: /labs/index.html
4
5
  title: Lab 0
5
6
  author: alex
6
7
  description: An example lab page for <%= site.title %>.
@@ -1,6 +1,7 @@
1
1
  ---
2
- parent: /labs/index.html
3
- parentLabel: Labs
2
+ breadcrumbs:
3
+ - label: Labs
4
+ url: /labs/index.html
4
5
  title: Lab 1
5
6
  author: alex
6
7
  description: A lab page for <%= site.title %> demonstrating the trace feature.
@@ -1,6 +1,7 @@
1
1
  ---
2
- parent: ../index.html
3
- parentLabel: Labs
2
+ breadcrumbs:
3
+ - label: Labs
4
+ url: ../index.html
4
5
  title: Lab 2
5
6
  author: alex
6
7
  description: A page demonstrating slides mode.
@@ -1,6 +1,7 @@
1
1
  ---
2
- parentLabel: Lectures
3
- parent: /lectures/index.html
2
+ breadcrumbs:
3
+ - label: Lectures
4
+ url: /lectures/index.html
4
5
  title: Lecture 1
5
6
  description: An example lecture page.
6
7
  author: alex
@@ -1,6 +1,7 @@
1
1
  ---
2
- parentLabel: Lectures
3
- parent: /lectures/index.html
2
+ breadcrumbs:
3
+ - label: Lectures
4
+ url: /lectures/index.html
4
5
  title: Lecture 2
5
6
  description: An example lecture page demonstrating partials.
6
7
  author: alex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.18.0",
3
+ "version": "1.18.1",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
package/src/_content.scss CHANGED
@@ -364,18 +364,34 @@ dfn {
364
364
  color: var(--theme-color-text);
365
365
  }
366
366
 
367
- a.breadcrumb {
368
- @include user-select(none);
369
- @include subdued-link;
367
+ .breadcrumbs {
368
+ ol {
369
+ display: flex;
370
+ flex-wrap: wrap;
371
+ gap: 0.25em;
372
+ padding: 0;
373
+ margin: 0;
374
+ list-style: none;
375
+ }
376
+
377
+ li {
378
+ display: flex;
379
+ gap: 0.25em;
380
+ align-items: center;
381
+ min-width: 0;
382
+ overflow-wrap: anywhere;
383
+ }
370
384
 
371
- &::after {
372
- display: inline-block;
373
- width: var(--icon-parent-size);
374
- height: var(--icon-parent-size);
375
- vertical-align: middle;
376
- content: '';
385
+ a {
386
+ @include subdued-link;
387
+ }
388
+
389
+ [aria-current='page'] {
390
+ font-style: italic;
391
+ }
377
392
 
378
- @include contained-mask(var(--icon-parent));
393
+ .material-symbol-icon {
394
+ flex-shrink: 0;
379
395
  }
380
396
  }
381
397
 
@@ -22,3 +22,10 @@
22
22
  --material-symbol-icon: var(--icon-contrast-high);
23
23
  --material-symbol-icon-size: var(--icon-contrast-high-size);
24
24
  }
25
+
26
+ .material-symbol-icon-breadcrumb-separator {
27
+ --material-symbol-icon: var(--icon-breadcrumb-separator);
28
+ --material-symbol-icon-size: var(--icon-breadcrumb-separator-size);
29
+
30
+ color: var(--fg2-color);
31
+ }
@@ -2,6 +2,11 @@ $shift: 8px;
2
2
  $duration: 150ms;
3
3
  $movement-easing: cubic-bezier(0.2, 0, 0, 1);
4
4
 
5
+ :root {
6
+ --page-transition-duration: #{$duration};
7
+ --page-transition-movement-easing: #{$movement-easing};
8
+ }
9
+
5
10
  @keyframes page-fade-out {
6
11
  to {
7
12
  opacity: 0;
@@ -45,12 +50,13 @@ $movement-easing: cubic-bezier(0.2, 0, 0, 1);
45
50
  ::view-transition-old(page-title),
46
51
  ::view-transition-new(page-title),
47
52
  ::view-transition-old(page-info),
48
- ::view-transition-new(page-info),
49
- ::view-transition-old(page-breadcrumb),
50
- ::view-transition-new(page-breadcrumb) {
53
+ ::view-transition-new(page-info) {
51
54
  animation-duration: $duration;
52
55
  }
53
56
 
57
+ // Breadcrumbs use per-item named rules installed by the navigator. Shared
58
+ // items stay opaque and preserve their position; removed/added suffixes fade.
59
+
54
60
  // The site-wide banner does not change between pages. Keep it out of the root
55
61
  // slide and pin its named transition layer in place without a crossfade.
56
62
  .site-banner {
@@ -0,0 +1,129 @@
1
+ interface BreadcrumbIdentity {
2
+ label: string;
3
+ url: string;
4
+ }
5
+
6
+ interface BreadcrumbItem extends BreadcrumbIdentity {
7
+ element: HTMLElement;
8
+ }
9
+
10
+ export function breadcrumbPageKey(href: string, pageUrl: string): string {
11
+ const url = new URL(href, pageUrl);
12
+ // Fragments identify positions on the same page; query strings can identify
13
+ // different content. URL serialization also resolves relative paths/spaces.
14
+ return url.origin + url.pathname + url.search;
15
+ }
16
+
17
+ export function sharedBreadcrumbCount(
18
+ oldItems: readonly BreadcrumbIdentity[],
19
+ newItems: readonly BreadcrumbIdentity[],
20
+ ): number {
21
+ let count = 0;
22
+ while (
23
+ count < Math.min(oldItems.length, newItems.length) &&
24
+ oldItems[count].url === newItems[count].url &&
25
+ oldItems[count].label === newItems[count].label
26
+ ) {
27
+ count++;
28
+ }
29
+ return count;
30
+ }
31
+
32
+ export function getBreadcrumbItems(
33
+ document: Document,
34
+ pageUrl: string,
35
+ ): BreadcrumbItem[] {
36
+ return Array.from(
37
+ document.querySelectorAll<HTMLElement>('nav.breadcrumbs ol > li'),
38
+ ).flatMap(element => {
39
+ const text = element.querySelector('a, [aria-current="page"]');
40
+ if (!text) {
41
+ return [];
42
+ }
43
+ const href = text.getAttribute('href') ?? pageUrl;
44
+ return [
45
+ {
46
+ element,
47
+ label: (text.textContent ?? '').trim().replace(/\s+/g, ' '),
48
+ url: breadcrumbPageKey(href, pageUrl),
49
+ },
50
+ ];
51
+ });
52
+ }
53
+
54
+ export function setBreadcrumbTransitionNames(
55
+ document: Document,
56
+ pageUrl: string,
57
+ sharedCount: number,
58
+ side: 'enter' | 'leave',
59
+ ): void {
60
+ const breadcrumb = document.querySelector<HTMLElement>('nav.breadcrumbs');
61
+ if (!breadcrumb) {
62
+ return;
63
+ }
64
+ getBreadcrumbItems(document, pageUrl).forEach((item, index) => {
65
+ const kind = index < sharedCount ? 'shared' : side;
66
+ item.element.style.viewTransitionName = `page-breadcrumb-${kind}-${index}`;
67
+ });
68
+ }
69
+
70
+ export function prepareBreadcrumbTransitionStyles(
71
+ document: Document,
72
+ oldCount: number,
73
+ newCount: number,
74
+ sharedCount: number,
75
+ ): void {
76
+ if (!oldCount && !newCount) {
77
+ return;
78
+ }
79
+ const style = document.createElement('style');
80
+ style.setAttribute('data-breadcrumb-transition-styles', '');
81
+ const rules: string[] = [];
82
+ const duration = 'var(--page-transition-duration)';
83
+ for (let index = 0; index < sharedCount; index++) {
84
+ const name = `page-breadcrumb-shared-${index}`;
85
+ rules.push(`
86
+ ::view-transition-group(${name}) {
87
+ animation-duration: ${duration};
88
+ animation-timing-function: var(--page-transition-movement-easing);
89
+ }
90
+ ::view-transition-old(${name}), ::view-transition-new(${name}) {
91
+ mix-blend-mode: normal;
92
+ animation: none;
93
+ }
94
+ ::view-transition-old(${name}) { display: none; }
95
+ ::view-transition-old(${name}):only-child {
96
+ display: block;
97
+ animation: page-fade-out ${duration} ease-in both;
98
+ }
99
+ ::view-transition-new(${name}):only-child {
100
+ animation: page-fade-in ${duration} ease-out both;
101
+ }
102
+ `);
103
+ }
104
+ for (let index = sharedCount; index < oldCount; index++) {
105
+ rules.push(`::view-transition-old(page-breadcrumb-leave-${index}) {
106
+ mix-blend-mode: normal;
107
+ animation: page-fade-out ${duration} ease-in both;
108
+ }`);
109
+ }
110
+ for (let index = sharedCount; index < newCount; index++) {
111
+ rules.push(`::view-transition-new(page-breadcrumb-enter-${index}) {
112
+ mix-blend-mode: normal;
113
+ animation: page-fade-in ${duration} ease-out both;
114
+ }`);
115
+ }
116
+ // Exact names keep these rules compatible with the bundled CSS parser, which
117
+ // does not yet accept view-transition-class selectors. Names use indices only.
118
+ style.textContent = rules.join('\n');
119
+ document.head.appendChild(style);
120
+ }
121
+
122
+ export function cleanupBreadcrumbTransitionNames(document: Document): void {
123
+ for (const element of document.querySelectorAll<HTMLElement>(
124
+ 'nav.breadcrumbs, nav.breadcrumbs li',
125
+ )) {
126
+ element.style.removeProperty('view-transition-name');
127
+ }
128
+ document.querySelector('[data-breadcrumb-transition-styles]')?.remove();
129
+ }
@@ -5,12 +5,22 @@ import {
5
5
  teardownPerPageComponents,
6
6
  } from './lifecycle';
7
7
  import { globals } from '../globals';
8
+ import {
9
+ cleanupBreadcrumbTransitionNames,
10
+ getBreadcrumbItems,
11
+ prepareBreadcrumbTransitionStyles,
12
+ setBreadcrumbTransitionNames,
13
+ sharedBreadcrumbCount,
14
+ } from './breadcrumbs';
8
15
 
9
16
  export const NAVIGATION_EVENT = 'tada:navigation';
10
17
 
11
18
  let currentAbortController: AbortController | null = null;
12
19
  let historyIndex = 0;
13
20
  let currentPath = '';
21
+ // Unlike the browser URL (which already changes on popstate), this tracks the
22
+ // page whose content is currently displayed, for relative breadcrumb identity.
23
+ let renderedPageUrl = '';
14
24
 
15
25
  const scrollByIndex = new Map<number, number>();
16
26
  const scrollByLocation = new Map<string, number>();
@@ -124,9 +134,6 @@ function cleanupViewTransitionNames(document: Document): void {
124
134
  const newInfo = document.querySelector(
125
135
  '.title-and-info .info',
126
136
  ) as HTMLElement | null;
127
- const newBreadcrumb = document.querySelector(
128
- '.title-and-info a.breadcrumb',
129
- ) as HTMLElement | null;
130
137
 
131
138
  if (newH1) {
132
139
  newH1.style.viewTransitionName = '';
@@ -134,9 +141,7 @@ function cleanupViewTransitionNames(document: Document): void {
134
141
  if (newInfo) {
135
142
  newInfo.style.viewTransitionName = '';
136
143
  }
137
- if (newBreadcrumb) {
138
- newBreadcrumb.style.viewTransitionName = '';
139
- }
144
+ cleanupBreadcrumbTransitionNames(document);
140
145
  }
141
146
 
142
147
  function getLocationKey(window: Window): string {
@@ -172,6 +177,7 @@ export function initNavigation(window: Window): void {
172
177
  scrollByLocation.clear();
173
178
  window.history.scrollRestoration = 'manual';
174
179
  currentPath = window.location.pathname + window.location.search;
180
+ renderedPageUrl = window.location.href;
175
181
  scrollByIndex.set(historyIndex, window.scrollY);
176
182
  scrollByLocation.set(getLocationKey(window), window.scrollY);
177
183
  }
@@ -305,6 +311,7 @@ export async function navigateToUrl(
305
311
  updateHead(document, newDoc);
306
312
  mountAppearancePickerForPage(window);
307
313
  currentPath = parsed.pathname + parsed.search;
314
+ renderedPageUrl = parsed.href;
308
315
 
309
316
  if (pushHistory) {
310
317
  historyIndex++;
@@ -336,6 +343,18 @@ export async function navigateToUrl(
336
343
  !prefersReducedMotion(window) &&
337
344
  typeof transitionDoc.startViewTransition === 'function'
338
345
  ) {
346
+ const oldBreadcrumbItems = getBreadcrumbItems(document, renderedPageUrl);
347
+ const newBreadcrumbItems = getBreadcrumbItems(newDoc, parsed.href);
348
+ const sharedCount = sharedBreadcrumbCount(
349
+ oldBreadcrumbItems,
350
+ newBreadcrumbItems,
351
+ );
352
+ prepareBreadcrumbTransitionStyles(
353
+ document,
354
+ oldBreadcrumbItems.length,
355
+ newBreadcrumbItems.length,
356
+ sharedCount,
357
+ );
339
358
  const titleEl = document.querySelector('.title-and-info');
340
359
  const headerHeight =
341
360
  document.querySelector('header')?.getBoundingClientRect().height ?? 0;
@@ -345,18 +364,18 @@ export async function navigateToUrl(
345
364
  if (titleVisible) {
346
365
  const h1 = titleEl.querySelector('h1') as HTMLElement | null;
347
366
  const info = titleEl.querySelector('.info') as HTMLElement | null;
348
- const breadcrumb = titleEl.querySelector(
349
- 'a.breadcrumb',
350
- ) as HTMLElement | null;
351
367
  if (h1) {
352
368
  h1.style.viewTransitionName = 'page-title';
353
369
  }
354
370
  if (info) {
355
371
  info.style.viewTransitionName = 'page-info';
356
372
  }
357
- if (breadcrumb) {
358
- breadcrumb.style.viewTransitionName = 'page-breadcrumb';
359
- }
373
+ setBreadcrumbTransitionNames(
374
+ document,
375
+ renderedPageUrl,
376
+ sharedCount,
377
+ 'leave',
378
+ );
360
379
  }
361
380
 
362
381
  document.documentElement.classList.add(
@@ -378,18 +397,18 @@ export async function navigateToUrl(
378
397
  if (newTitleVisible && newTitleEl) {
379
398
  const h1 = newTitleEl.querySelector('h1') as HTMLElement | null;
380
399
  const info = newTitleEl.querySelector('.info') as HTMLElement | null;
381
- const breadcrumb = newTitleEl.querySelector(
382
- 'a.breadcrumb',
383
- ) as HTMLElement | null;
384
400
  if (h1) {
385
401
  h1.style.viewTransitionName = 'page-title';
386
402
  }
387
403
  if (info) {
388
404
  info.style.viewTransitionName = 'page-info';
389
405
  }
390
- if (breadcrumb) {
391
- breadcrumb.style.viewTransitionName = 'page-breadcrumb';
392
- }
406
+ setBreadcrumbTransitionNames(
407
+ document,
408
+ renderedPageUrl,
409
+ sharedCount,
410
+ 'enter',
411
+ );
393
412
  }
394
413
  });
395
414
 
@@ -1,6 +1,19 @@
1
1
  <div class="title-and-info" data-pagefind-body>
2
- <% if (page.parent) { %>
3
- <a class="breadcrumb" href="<%- encodeAuthoredUrl(page.parent) %>" data-pagefind-ignore><%- page.parentLabel %></a>
2
+ <% if (page.breadcrumbs && page.breadcrumbs.length) { %>
3
+ <nav class="breadcrumbs" aria-label="Breadcrumb" data-pagefind-ignore>
4
+ <ol>
5
+ <% page.breadcrumbs.forEach((entry, index) => { %>
6
+ <li>
7
+ <% if (index > 0) { %><span class="material-symbol-icon material-symbol-icon-breadcrumb-separator" aria-hidden="true"></span><% } %>
8
+ <a href="<%- encodeAuthoredUrl(entry.url) %>"><%- entry.label %></a>
9
+ </li>
10
+ <% }); %>
11
+ <li>
12
+ <span class="material-symbol-icon material-symbol-icon-breadcrumb-separator" aria-hidden="true"></span>
13
+ <span aria-current="page"><%- page.title %></span>
14
+ </li>
15
+ </ol>
16
+ </nav>
4
17
  <% } %>
5
18
  <h1 data-pagefind-weight="10" data-pagefind-meta="title_html[data-title-html]" data-title-html="<%- page.titleHtml %>"><%= page.titleHtml %></h1>
6
19
 
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path d="M386-224v-58h250.69L200-718.69 241.31-760 678-323.31V-574h58v350H386Z"/></svg>