@tutorialkit-rb/astro 0.1.4

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -0
  3. package/dist/default/components/DownloadButton.tsx +44 -0
  4. package/dist/default/components/HeadTags.astro +3 -0
  5. package/dist/default/components/LoginButton.tsx +55 -0
  6. package/dist/default/components/Logo.astro +30 -0
  7. package/dist/default/components/MainContainer.astro +86 -0
  8. package/dist/default/components/MetaTags.astro +44 -0
  9. package/dist/default/components/MobileContentToggle.astro +44 -0
  10. package/dist/default/components/NavCard.astro +23 -0
  11. package/dist/default/components/NavWrapper.tsx +11 -0
  12. package/dist/default/components/OpenInStackblitzLink.tsx +37 -0
  13. package/dist/default/components/PageLoadingIndicator.astro +66 -0
  14. package/dist/default/components/ResizablePanel.astro +247 -0
  15. package/dist/default/components/ThemeSwitch.tsx +24 -0
  16. package/dist/default/components/TopBar.astro +20 -0
  17. package/dist/default/components/TopBarWrapper.astro +30 -0
  18. package/dist/default/components/TutorialContent.astro +48 -0
  19. package/dist/default/components/WorkspacePanelWrapper.tsx +25 -0
  20. package/dist/default/components/setup.ts +20 -0
  21. package/dist/default/components/webcontainer.ts +46 -0
  22. package/dist/default/env-default.d.ts +19 -0
  23. package/dist/default/layouts/Layout.astro +98 -0
  24. package/dist/default/pages/[...slug].astro +39 -0
  25. package/dist/default/pages/index.astro +25 -0
  26. package/dist/default/stores/auth-store.ts +6 -0
  27. package/dist/default/stores/theme-store.ts +32 -0
  28. package/dist/default/stores/view-store.ts +5 -0
  29. package/dist/default/styles/base.css +11 -0
  30. package/dist/default/styles/markdown.css +400 -0
  31. package/dist/default/styles/panel.css +7 -0
  32. package/dist/default/styles/variables.css +396 -0
  33. package/dist/default/utils/constants.ts +6 -0
  34. package/dist/default/utils/content/files-ref.ts +25 -0
  35. package/dist/default/utils/content/squash.ts +37 -0
  36. package/dist/default/utils/content.ts +446 -0
  37. package/dist/default/utils/logger.ts +56 -0
  38. package/dist/default/utils/logo.ts +17 -0
  39. package/dist/default/utils/nav.ts +65 -0
  40. package/dist/default/utils/publicAsset.ts +27 -0
  41. package/dist/default/utils/routes.ts +34 -0
  42. package/dist/default/utils/url.ts +22 -0
  43. package/dist/default/utils/workspace.ts +31 -0
  44. package/dist/index.d.ts +57 -0
  45. package/dist/index.js +972 -0
  46. package/dist/integrations.d.ts +10 -0
  47. package/dist/remark/callouts.d.ts +3 -0
  48. package/dist/remark/import-file.d.ts +7 -0
  49. package/dist/remark/index.d.ts +2 -0
  50. package/dist/types.d.ts +9 -0
  51. package/dist/utils.d.ts +2 -0
  52. package/dist/vite-plugins/core.d.ts +2 -0
  53. package/dist/vite-plugins/css.d.ts +4 -0
  54. package/dist/vite-plugins/override-components.d.ts +78 -0
  55. package/dist/vite-plugins/store.d.ts +2 -0
  56. package/dist/webcontainer-files/cache.d.ts +21 -0
  57. package/dist/webcontainer-files/cache.spec.d.ts +1 -0
  58. package/dist/webcontainer-files/constants.d.ts +4 -0
  59. package/dist/webcontainer-files/filesmap.d.ts +38 -0
  60. package/dist/webcontainer-files/filesmap.spec.d.ts +1 -0
  61. package/dist/webcontainer-files/index.d.ts +8 -0
  62. package/dist/webcontainer-files/utils.d.ts +6 -0
  63. package/package.json +80 -0
  64. package/types.d.ts +12 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 StackBlitz, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @tutorialkit/astro
2
+
3
+ This **[Astro integration][astro-integration]** adds [TutorialKit](https://tutorialkit.dev/) to your project so that you can use TutorialKit's tutorial format for your astro content.
4
+
5
+ This integration adds routes to serve your tutorial. It uses `@tutorialkit/react` for the dynamic part of the experience.
6
+
7
+ ## License
8
+
9
+ MIT
10
+
11
+ Copyright (c) 2023–present [StackBlitz][stackblitz]
12
+
13
+ [stackblitz]: https://stackblitz.com/
14
+ [astro-integration]: https://docs.astro.build/en/guides/integrations-guide/
@@ -0,0 +1,44 @@
1
+ import { tutorialStore, webcontainer as webcontainerPromise } from './webcontainer.js';
2
+
3
+ export function DownloadButton() {
4
+ return (
5
+ <button
6
+ title="Download lesson as zip-file"
7
+ className="flex items-center text-2xl text-tk-elements-topBar-iconButton-iconColor hover:text-tk-elements-topBar-iconButton-iconColorHover transition-theme bg-tk-elements-topBar-iconButton-backgroundColor hover:bg-tk-elements-topBar-iconButton-backgroundColorHover p-1 rounded-md"
8
+ onClick={onClick}
9
+ >
10
+ <div className="i-ph-download-simple" />
11
+ </button>
12
+ );
13
+ }
14
+
15
+ async function onClick() {
16
+ const lesson = tutorialStore.lesson;
17
+
18
+ if (!lesson) {
19
+ throw new Error('Missing lesson');
20
+ }
21
+
22
+ const webcontainer = await webcontainerPromise;
23
+ const data = await webcontainer.export('/home/tutorial', { format: 'zip', excludes: ['node_modules'] });
24
+
25
+ let filename =
26
+ typeof lesson.data.downloadAsZip === 'object'
27
+ ? lesson.data.downloadAsZip.filename
28
+ : [lesson.part?.id, lesson.chapter?.id, lesson.id].filter(Boolean).join('-');
29
+
30
+ if (!filename.endsWith('.zip')) {
31
+ filename += '.zip';
32
+ }
33
+
34
+ const link = document.createElement('a');
35
+ link.style.display = 'none';
36
+ link.download = filename;
37
+ link.href = URL.createObjectURL(new Blob([data], { type: 'application/zip' }));
38
+
39
+ document.body.appendChild(link);
40
+ link.click();
41
+
42
+ document.body.removeChild(link);
43
+ URL.revokeObjectURL(link.href);
44
+ }
@@ -0,0 +1,3 @@
1
+ <slot name="title" />
2
+ <slot name="links" />
3
+ <slot name="meta" />
@@ -0,0 +1,55 @@
1
+ import { useStore } from '@nanostores/react';
2
+ import { Button } from '@tutorialkit-rb/react';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { authStore } from '../stores/auth-store';
5
+ import { login, logout } from './webcontainer';
6
+
7
+ export function LoginButton() {
8
+ // using any because @types/node are included in that context although they shouldn't
9
+ const timeoutId = useRef<any>(0);
10
+ const authStatus = useStore(authStore);
11
+
12
+ // using an indirect state so that there's no hydratation errors
13
+ const [showLogin, setShowLogin] = useState(true);
14
+ const [disabled, setDisabled] = useState(false);
15
+
16
+ function onClick() {
17
+ if (showLogin) {
18
+ setDisabled(true);
19
+ clearTimeout(timeoutId.current);
20
+
21
+ /**
22
+ * Re-enable the button in case the login failed because the popup got stuck in an error or
23
+ * was closed before the authorization step was completed.
24
+ */
25
+ timeoutId.current = setTimeout(() => {
26
+ setDisabled(false);
27
+ }, 1000);
28
+
29
+ login().then(() => {
30
+ authStore.set({ status: 'authorized' });
31
+ });
32
+ } else {
33
+ logout();
34
+ }
35
+ }
36
+
37
+ useEffect(() => {
38
+ setShowLogin(authStatus.status !== 'authorized');
39
+
40
+ // if authentication failed we invite the user to try again
41
+ setDisabled((disabled) => {
42
+ if (disabled && authStatus.status === 'auth-failed') {
43
+ return false;
44
+ }
45
+
46
+ return disabled;
47
+ });
48
+ }, [authStatus.status]);
49
+
50
+ return (
51
+ <Button className="ml-2" variant={showLogin ? 'primary' : 'secondary'} disabled={disabled} onClick={onClick}>
52
+ {showLogin ? 'Login' : 'Logout'}
53
+ </Button>
54
+ );
55
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ import { LOGO_EXTENSIONS } from '../utils/constants';
3
+ import { readLogoFile } from '../utils/logo';
4
+
5
+ interface Props {
6
+ logoLink: string;
7
+ }
8
+
9
+ const { logoLink } = Astro.props;
10
+
11
+ const logo = readLogoFile('logo');
12
+ const logoDark = readLogoFile('logo-dark') ?? logo;
13
+
14
+ if (!logo) {
15
+ console.warn(
16
+ [
17
+ `No logo found in public/. Supported filenames are: logo.(${LOGO_EXTENSIONS.join('|')}). `,
18
+ `You can overwrite the logo for dark mode by providing a logo-dark.(${LOGO_EXTENSIONS.join('|')}).`,
19
+ ].join(''),
20
+ );
21
+ }
22
+ ---
23
+
24
+ <a
25
+ href={logoLink}
26
+ class="flex items-center text-tk-elements-topBar-logo-color hover:text-tk-elements-topBar-logo-colorHover"
27
+ >
28
+ {logo && <img class="h-5 w-auto dark:hidden" src={logo} />}
29
+ {logo && <img class="h-5 w-auto hidden dark:inline-block" src={logoDark} />}
30
+ </a>
@@ -0,0 +1,86 @@
1
+ ---
2
+ import { NavWrapper as Nav } from './NavWrapper';
3
+ import { WorkspacePanelWrapper as WorkspacePanel } from './WorkspacePanelWrapper';
4
+ import TutorialContent from './TutorialContent.astro';
5
+ import ResizablePanel from './ResizablePanel.astro';
6
+ import MobileContentToggle from './MobileContentToggle.astro';
7
+ import { RESIZABLE_PANELS } from '../utils/constants';
8
+ import type { Lesson, NavList } from '@tutorialkit-rb/types';
9
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
10
+ import { hasWorkspace } from '../utils/workspace';
11
+
12
+ interface Props {
13
+ lesson: Lesson<AstroComponentFactory>;
14
+ navList: NavList;
15
+ }
16
+
17
+ const { lesson, navList } = Astro.props;
18
+
19
+ const showWorkspacePanel = hasWorkspace(lesson);
20
+ ---
21
+
22
+ <ResizablePanel
23
+ class="h-full overflow-hidden"
24
+ id={RESIZABLE_PANELS.Main}
25
+ type="horizontal"
26
+ min="30%"
27
+ pos="40%"
28
+ max="60%"
29
+ >
30
+ <div
31
+ class="h-full flex flex-col transition-theme bg-tk-elements-app-backgroundColor text-tk-elements-app-textColor"
32
+ slot="a"
33
+ >
34
+ <Nav client:load lesson={lesson} navList={navList} />
35
+ <TutorialContent lesson={lesson} />
36
+ </div>
37
+ <div
38
+ class="h-full sm:border-l transition-theme border-tk-elements-app-borderColor"
39
+ slot={showWorkspacePanel ? 'b' : 'hide'}
40
+ >
41
+ <WorkspacePanel lesson={lesson} client:load transition:persist />
42
+ </div>
43
+ </ResizablePanel>
44
+ <MobileContentToggle />
45
+ <script>
46
+ import { viewStore } from '../stores/view-store';
47
+ import { RESIZABLE_PANELS } from '../utils/constants';
48
+ import type { IResizablePanel } from './ResizablePanel.astro';
49
+
50
+ const DEFAULT_PANEL_CLASS_LIST = ['sm:transition-none', 'sm:translate-x-0', 'absolute', 'inset-0', 'sm:static'];
51
+
52
+ let subscriber: (() => void) | undefined;
53
+
54
+ document.addEventListener('astro:page-load', () => {
55
+ subscriber?.();
56
+
57
+ const resizablePanel = document.querySelector(`[data-id=${RESIZABLE_PANELS.Main}]`) as unknown as IResizablePanel;
58
+ const contentPanel = resizablePanel.mainPanel();
59
+ const editorPanel = resizablePanel.sidePanel();
60
+ const divider = resizablePanel.divider();
61
+
62
+ if (!editorPanel) {
63
+ subscriber = undefined;
64
+ return;
65
+ }
66
+
67
+ contentPanel.classList.add(...DEFAULT_PANEL_CLASS_LIST);
68
+ editorPanel.classList.add(...DEFAULT_PANEL_CLASS_LIST);
69
+ divider?.classList.add('hidden', 'sm:block');
70
+
71
+ subscriber = viewStore.subscribe((value) => {
72
+ if (value === 'content') {
73
+ contentPanel.classList.remove('-translate-x-full');
74
+ editorPanel.classList.add('translate-x-full');
75
+ } else {
76
+ contentPanel.classList.add('-translate-x-full');
77
+ editorPanel.classList.remove('translate-x-full');
78
+ }
79
+ });
80
+
81
+ requestAnimationFrame(() => {
82
+ contentPanel.classList.add('transition-transform');
83
+ editorPanel.classList.add('transition-transform');
84
+ });
85
+ });
86
+ </script>
@@ -0,0 +1,44 @@
1
+ ---
2
+ import type { MetaTagsConfig } from '@tutorialkit-rb/types';
3
+ import { readLogoFile } from '../utils/logo';
4
+ import { readPublicAsset } from '../utils/publicAsset';
5
+
6
+ interface Props {
7
+ meta?: MetaTagsConfig;
8
+ }
9
+
10
+ const DEFAULT_OG_IMAGE = 'https://tutorialkit.dev/tutorialkit-opengraph.png';
11
+
12
+ const { meta = {} } = Astro.props;
13
+ let imageUrl = meta.image;
14
+
15
+ // Resolve relative paths to /public folder
16
+ if (imageUrl?.startsWith('/') || imageUrl?.startsWith('.')) {
17
+ imageUrl = readPublicAsset(imageUrl, true);
18
+
19
+ if (!imageUrl) {
20
+ console.warn(`\nImage ${meta.image} not found in "/public" folder`);
21
+ }
22
+ }
23
+
24
+ imageUrl ??= DEFAULT_OG_IMAGE;
25
+
26
+ if (imageUrl?.endsWith('.svg')) {
27
+ console.warn(
28
+ `\nUsing a SVG open graph image "${imageUrl}". This is not supported by most social platforms. You should rather set "meta.image" to a raster image (PNG, WEBP).`,
29
+ );
30
+ }
31
+ ---
32
+
33
+ <meta charset="UTF-8" />
34
+ <meta name="viewport" content="width=device-width" />
35
+ <meta name="generator" content={Astro.generator} />
36
+ {meta.description ? <meta name="description" content={meta.description} /> : null}
37
+ {/* open graph */}
38
+ {meta.title ? <meta name="og:title" content={meta.title} /> : null}
39
+ {meta.description ? <meta name="og:description" content={meta.description} /> : null}
40
+ {imageUrl ? <meta name="og:image" content={imageUrl} /> : null}
41
+ {/* twitter */}
42
+ {meta.title ? <meta name="twitter:title" content={meta.title} /> : null}
43
+ {meta.description ? <meta name="twitter:description" content={meta.description} /> : null}
44
+ {imageUrl ? <meta name="twitter:image" content={imageUrl} /> : null}
@@ -0,0 +1,44 @@
1
+ <div class="h-12 sm:hidden"></div>
2
+ <view-toggle
3
+ class="fixed sm:hidden h-12 bottom-0 w-full transition-theme bg-tk-elements-app-backgroundColor border-t border-tk-elements-app-borderColor flex justify-center items-center z-60 text-tk-elements-app-textColor"
4
+ >
5
+ <button>Tutorial</button>
6
+ <button class="rounded-full w-8 h-4 p-0.5 bg-gray-4 dark:bg-gray-6 mx-2 relative">
7
+ <span class="inline-block transition-all absolute top-0.5 left-0.5 rounded-full bg-white dark:bg-gray-2 w-3 h-3"
8
+ ></span>
9
+ </button>
10
+ <button>Editor</button>
11
+ </view-toggle>
12
+ <script>
13
+ import { viewStore, type View } from '../stores/view-store';
14
+
15
+ class ViewToggle extends HTMLElement {
16
+ constructor() {
17
+ super();
18
+
19
+ const [tutorialBtn, toggleButton, editorButton] = this.querySelectorAll(':scope > button' as 'button');
20
+
21
+ const toggle = toggleButton.querySelector('span')!;
22
+
23
+ tutorialBtn.onclick = () => setView('content');
24
+ editorButton.onclick = () => setView('editor');
25
+ toggleButton.onclick = () => setView(viewStore.get() === 'content' ? 'editor' : 'content');
26
+
27
+ setView(viewStore.get());
28
+
29
+ function setView(view: View) {
30
+ viewStore.set(view);
31
+
32
+ if (view === 'editor') {
33
+ toggle.classList.remove('left-0.5');
34
+ toggle.classList.add('left-[calc(100%-0.75rem-0.125rem)]');
35
+ } else {
36
+ toggle.classList.add('left-0.5');
37
+ toggle.classList.remove('left-[calc(100%-0.75rem-0.125rem)]');
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ customElements.define('view-toggle', ViewToggle);
44
+ </script>
@@ -0,0 +1,23 @@
1
+ ---
2
+ import type { LessonLink } from '@tutorialkit-rb/types';
3
+
4
+ interface Props {
5
+ lesson: LessonLink;
6
+ type: 'next' | 'prev';
7
+ }
8
+
9
+ const { lesson, type } = Astro.props;
10
+ ---
11
+
12
+ <a
13
+ class:list={[
14
+ 'content-nav-card group flex flex-col transition-theme bg-tk-elements-navCard-backgroundColor hover:bg-tk-elements-navCard-backgroundColorHover flex-1 p-4 border border-tk-elements-navCard-borderColor hover:border-tk-elements-navCard-borderColorHover text-tk-elements-navCard-textColor hover:text-tk-elements-navCard-textColorHover rounded-md cursor-pointer',
15
+ { 'text-right items-end': type === 'next' },
16
+ ]}
17
+ href={lesson.href}
18
+ >
19
+ <span
20
+ class=`icon text-tk-elements-navCard-iconColor ${type === 'prev' ? 'i-ph-arrow-left' : 'i-ph-arrow-right' } pointer-events-none h-7 w-7 mb-2 group-hover:text-tk-elements-navCard-iconColorHover`
21
+ ></span>
22
+ <span class="line-clamp-2">{lesson.title}</span>
23
+ </a>
@@ -0,0 +1,11 @@
1
+ import { Nav } from '@tutorialkit-rb/react';
2
+ import type { Lesson, NavList } from '@tutorialkit-rb/types';
3
+
4
+ interface Props {
5
+ lesson: Lesson;
6
+ navList: NavList;
7
+ }
8
+
9
+ export function NavWrapper(props: Props) {
10
+ return <Nav {...props} />;
11
+ }
@@ -0,0 +1,37 @@
1
+ import StackBlitzSDK from '@stackblitz/sdk';
2
+ import { tutorialStore } from './webcontainer.js';
3
+
4
+ export function OpenInStackblitzLink() {
5
+ return (
6
+ <button
7
+ title="Open in StackBlitz"
8
+ className="flex items-center font-size-3.5 text-tk-elements-topBar-iconButton-iconColor hover:text-tk-elements-topBar-iconButton-iconColorHover transition-theme bg-tk-elements-topBar-iconButton-backgroundColor hover:bg-tk-elements-topBar-iconButton-backgroundColorHover p-1 rounded-md"
9
+ onClick={onClick}
10
+ >
11
+ <svg viewBox="0 0 28 28" aria-hidden="true" height="24" width="24">
12
+ <path
13
+ fill="currentColor"
14
+ d="M12.747 16.273h-7.46L18.925 1.5l-3.671 10.227h7.46L9.075 26.5l3.671-10.227z"
15
+ ></path>
16
+ </svg>
17
+ </button>
18
+ );
19
+ }
20
+
21
+ function onClick() {
22
+ const lesson = tutorialStore.lesson;
23
+
24
+ if (!lesson) {
25
+ throw new Error('Missing lesson');
26
+ }
27
+
28
+ const snapshot = tutorialStore.takeSnapshot();
29
+ const options = typeof lesson.data.openInStackBlitz === 'object' ? lesson.data.openInStackBlitz : {};
30
+
31
+ StackBlitzSDK.openProject({
32
+ title: options.projectTitle || 'Project generated by TutorialKit',
33
+ description: options.projectDescription,
34
+ template: options.projectTemplate || 'node',
35
+ files: snapshot.files,
36
+ });
37
+ }
@@ -0,0 +1,66 @@
1
+ <div
2
+ data-id="page-loading-progress"
3
+ class="fixed transition-all top-0 z-90 h-[2px] opacity-100 pointer-events-none bg-tk-elements-pageLoadingIndicator-backgroundColor"
4
+ >
5
+ <div
6
+ class="absolute right-0 w-24 h-full shadow-[0px_0px_10px_0px] shadow-tk-elements-pageLoadingIndicator-shadowColor"
7
+ >
8
+ </div>
9
+ </div>
10
+ <script>
11
+ const progressEl = document.querySelector('div[data-id="page-loading-progress"]' as 'div')!;
12
+ const storageKey = 'tk_plid';
13
+ const maxDurationWithoutProgressBar = 500;
14
+
15
+ let expectedDuration = parseFloat(localStorage.getItem(storageKey) || `${maxDurationWithoutProgressBar}`);
16
+
17
+ let intervalId: ReturnType<typeof setInterval> | undefined;
18
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
19
+ let startTime = Date.now();
20
+
21
+ function start() {
22
+ clearTimeout(timeoutId);
23
+
24
+ startTime = Date.now();
25
+
26
+ progressEl.style.width = '0%';
27
+ progressEl.style.opacity = '0';
28
+
29
+ intervalId = setInterval(() => {
30
+ const elapsedTime = Date.now() - startTime;
31
+
32
+ // we're past the 500ms mark and we're about to make the bar visible, extend the expectedDuration a bit
33
+ if (elapsedTime > maxDurationWithoutProgressBar && expectedDuration <= maxDurationWithoutProgressBar) {
34
+ // this a bit arbitrary to make it look "good"
35
+ expectedDuration += 2 * maxDurationWithoutProgressBar;
36
+ }
37
+
38
+ // if expected duration is less we don't show anything
39
+ if (expectedDuration < maxDurationWithoutProgressBar) {
40
+ return;
41
+ }
42
+
43
+ progressEl.style.opacity = '1';
44
+ progressEl.style.width = `${Math.min(elapsedTime / expectedDuration, 1) * 100}%`;
45
+ }, 100);
46
+ }
47
+
48
+ function done() {
49
+ clearInterval(intervalId);
50
+ progressEl.style.width = '100%';
51
+
52
+ expectedDuration = Date.now() - startTime;
53
+ localStorage.setItem(storageKey, expectedDuration.toString());
54
+
55
+ timeoutId = setTimeout(() => {
56
+ progressEl.style.opacity = '0';
57
+
58
+ timeoutId = setTimeout(() => {
59
+ progressEl.style.width = '0%';
60
+ }, 100);
61
+ }, 200);
62
+ }
63
+
64
+ document.addEventListener('astro:before-preparation', start);
65
+ document.addEventListener('astro:after-swap', done);
66
+ </script>