Skip to content

Commit 9c6e534

Browse files
authored
feat(astro): add "Download lesson as zip" button (#415)
1 parent 8c44cbe commit 9c6e534

File tree

30 files changed

+455
-77
lines changed

30 files changed

+455
-77
lines changed

docs/tutorialkit.dev/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
},
1313
"dependencies": {
1414
"@tutorialkit/react": "workspace:*",
15-
"@webcontainer/api": "1.2.4",
15+
"@webcontainer/api": "1.5.1",
1616
"classnames": "^2.5.1",
1717
"react": "^18.3.1",
1818
"react-dom": "^18.3.1"

docs/tutorialkit.dev/src/content/docs/guides/overriding-components.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ When overriding `TopBar` you can place TutorialKit's default components using fo
4646

4747
- `logo`: Logo of the application
4848
- `open-in-stackblitz-link`: Link for opening current lesson in StackBlitz
49+
- `download-button`: Button for downloading current lesson as `.zip` file
4950
- `theme-switch`: Switch for changing the theme
5051
- `login-button`: For StackBlitz Enterprise user, the login button
5152

@@ -61,6 +62,8 @@ When overriding `TopBar` you can place TutorialKit's default components using fo
6162

6263
<LanguageSelect />
6364

65+
<slot name="download-button" />
66+
6467
<slot name="open-in-stackblitz-link" />
6568

6669
<slot name="login-button" />

docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,20 @@ type TemplateType = "html" | "node" | "angular-cli" | "create-react-app" | "java
412412
413413
```
414414

415+
### `downloadAsZip`
416+
Display a button for downloading the current lesson as `.zip` file. Defaults to `false`.
417+
The default filename is constructed by concatenating folder names of part, chapter and lesson.
418+
<PropertyTable inherited type="DownloadAsZip" />
419+
420+
The `DownloadAsZip` type has the following shape:
421+
422+
```ts
423+
type DownloadAsZip =
424+
| boolean
425+
| { filename?: string }
426+
427+
```
428+
415429
##### `meta`
416430

417431
Configures `<meta>` tags for Open Graph protocole and Twitter.

e2e/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"playwright": "^1.46.0",
3535
"react": "^18.3.1",
3636
"react-dom": "^18.3.1",
37-
"unocss": "^0.59.4"
37+
"unocss": "^0.59.4",
38+
"unzipper": "^0.12.3"
3839
}
3940
}

e2e/src/components/TopBar.astro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77

88
<div class="mr-2 color-tk-text-primary">Custom Top Bar Mounted</div>
99

10+
<div class="mr-2">
11+
<slot name="download-button" />
12+
</div>
13+
1014
<div class="mr-2">
1115
<slot name="open-in-stackblitz-link" />
1216
</div>

e2e/src/content/tutorial/meta.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
type: tutorial
33
mainCommand: ''
44
prepareCommands: []
5+
downloadAsZip: true
56
---

e2e/test/topbar.override-components.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ test('developer can override TopBar', async ({ page }) => {
77
await expect(nav.getByText('Custom Top Bar Mounted')).toBeVisible();
88

99
// default elements should also be visible
10+
await expect(nav.getByRole('button', { name: 'Download lesson as zip-file' })).toBeVisible();
1011
await expect(nav.getByRole('button', { name: 'Open in StackBlitz' })).toBeVisible();
1112
await expect(nav.getByRole('button', { name: 'Toggle Theme' })).toBeVisible();
1213
});

e2e/test/topbar.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/// <reference types="node" />
2+
import { readdirSync, readFileSync, rmSync } from 'node:fs';
3+
import type { Readable } from 'node:stream';
4+
import { test, expect } from '@playwright/test';
5+
import * as unzipper from 'unzipper';
6+
import { theme } from '../../packages/theme/src/theme';
7+
8+
test('user can change theme', async ({ page }) => {
9+
await page.goto('/');
10+
11+
const heading = page.getByRole('heading', { level: 1 });
12+
const html = page.locator('html');
13+
14+
// default light theme
15+
await expect(html).toHaveAttribute('data-theme', 'light');
16+
await expect(heading).toHaveCSS('color', hexToRGB(theme.colors.gray[800]));
17+
18+
await page.getByRole('navigation').getByRole('button', { name: 'Toggle Theme' }).click();
19+
20+
await expect(html).toHaveAttribute('data-theme', 'dark');
21+
await expect(heading).toHaveCSS('color', hexToRGB(theme.colors.gray[200]));
22+
});
23+
24+
test('user can download project as zip', async ({ page }) => {
25+
await page.goto('/', { waitUntil: 'networkidle' });
26+
27+
const downloadPromise = page.waitForEvent('download');
28+
await page.getByRole('navigation').getByRole('button', { name: 'Download lesson as zip-file' }).click();
29+
30+
const download = await downloadPromise;
31+
expect(download.suggestedFilename()).toBe('tests-file-tree-allow-edits-disabled.zip');
32+
33+
const stream = await download.createReadStream();
34+
const files = await unzip(stream);
35+
36+
expect(files).toMatchObject({
37+
'./tutorial/file-on-template.js': "export default 'This file is present on template';\n",
38+
'./tutorial/first-level/file.js': "export default 'File in first level';\n",
39+
'./tutorial/first-level/second-level/file.js': "export default 'File in second level';\n",
40+
});
41+
42+
expect(files['./tutorial/index.mjs']).toMatch("import http from 'node:http'");
43+
});
44+
45+
function hexToRGB(hex: string) {
46+
return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
47+
}
48+
49+
async function unzip(stream: Readable) {
50+
await stream.pipe(unzipper.Extract({ path: './downloads' })).promise();
51+
52+
const files = readDirectoryContents('./downloads');
53+
rmSync('./downloads', { recursive: true });
54+
55+
return files.reduce(
56+
(all, current) => ({
57+
...all,
58+
[current.name.replace('/downloads', '')]: current.content,
59+
}),
60+
{},
61+
);
62+
}
63+
64+
function readDirectoryContents(directory: string) {
65+
const files: { name: string; content: string }[] = [];
66+
67+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
68+
const name = `${directory}/${entry.name}`;
69+
70+
if (entry.isFile()) {
71+
files.push({ name, content: readFileSync(name, 'utf-8') });
72+
} else if (entry.isDirectory()) {
73+
files.push(...readDirectoryContents(name));
74+
}
75+
}
76+
77+
return files;
78+
}

packages/astro/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"@tutorialkit/types": "workspace:*",
4646
"@types/react": "^18.3.3",
4747
"@unocss/reset": "^0.62.2",
48-
"@webcontainer/api": "1.2.4",
48+
"@webcontainer/api": "1.5.1",
4949
"astro": "^4.15.0",
5050
"astro-expressive-code": "^0.35.3",
5151
"chokidar": "3.6.0",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

packages/astro/src/default/components/TopBar.astro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
<div class="flex flex-1">
55
<slot name="logo" />
66
</div>
7+
8+
<div class="mr-2">
9+
<slot name="download-button" />
10+
</div>
711
<div class="mr-2">
812
<slot name="open-in-stackblitz-link" />
913
</div>

packages/astro/src/default/components/TopBarWrapper.astro

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,25 @@ import { TopBar } from 'tutorialkit:override-components';
33
import type { Lesson } from '@tutorialkit/types';
44
import { ThemeSwitch } from './ThemeSwitch';
55
import { LoginButton } from './LoginButton';
6+
import { DownloadButton } from './DownloadButton';
67
import { OpenInStackblitzLink } from './OpenInStackblitzLink';
78
import Logo from './Logo.astro';
89
import { useAuth } from './setup';
910
1011
interface Props {
1112
logoLink: string;
1213
openInStackBlitz: Lesson['data']['openInStackBlitz'];
14+
downloadAsZip: Lesson['data']['downloadAsZip'];
1315
}
1416
15-
const { logoLink, openInStackBlitz } = Astro.props;
17+
const { logoLink, openInStackBlitz, downloadAsZip } = Astro.props;
1618
---
1719

1820
<TopBar>
1921
<Logo slot="logo" logoLink={logoLink ?? '/'} />
2022

23+
{downloadAsZip && <DownloadButton client:load transition:persist slot="download-button" />}
24+
2125
{openInStackBlitz && <OpenInStackblitzLink client:load transition:persist slot="open-in-stackblitz-link" />}
2226

2327
<ThemeSwitch client:load transition:persist slot="theme-switch" />

packages/astro/src/default/pages/[...slug].astro

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,16 @@ meta.description ??= 'A TutorialKit interactive lesson';
2424

2525
<Layout title={title} meta={meta}>
2626
<PageLoadingIndicator />
27+
2728
<div id="previews-container" style="display: none;"></div>
29+
2830
<main class="max-w-full flex flex-col h-full overflow-hidden" data-swap-root>
29-
<TopBarWrapper logoLink={logoLink ?? '/'} openInStackBlitz={lesson.data.openInStackBlitz} />
31+
<TopBarWrapper
32+
logoLink={logoLink ?? '/'}
33+
openInStackBlitz={lesson.data.openInStackBlitz}
34+
downloadAsZip={lesson.data.downloadAsZip}
35+
/>
36+
3037
<MainContainer lesson={lesson} navList={navList} />
3138
</main>
3239
</Layout>

packages/astro/src/default/utils/__snapshots__/multiple-parts.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@
7070
"i18n": {
7171
"mocked": "default localization"
7272
},
73-
"openInStackBlitz": true
73+
"openInStackBlitz": true,
74+
"downloadAsZip": false
7475
},
7576
"id": "1-first",
7677
"filepath": "1-part/1-chapter/1-first/content.md",
@@ -106,7 +107,8 @@
106107
"i18n": {
107108
"mocked": "default localization"
108109
},
109-
"openInStackBlitz": true
110+
"openInStackBlitz": true,
111+
"downloadAsZip": false
110112
},
111113
"id": "1-second",
112114
"filepath": "2-part/2-chapter/1-second/content.md",
@@ -146,7 +148,8 @@
146148
"i18n": {
147149
"mocked": "default localization"
148150
},
149-
"openInStackBlitz": true
151+
"openInStackBlitz": true,
152+
"downloadAsZip": false
150153
},
151154
"id": "1-third",
152155
"filepath": "3-part/3-chapter/1-third/content.md",

packages/astro/src/default/utils/__snapshots__/single-lesson-no-part.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"i18n": {
1010
"mocked": "default localization"
1111
},
12-
"openInStackBlitz": true
12+
"openInStackBlitz": true,
13+
"downloadAsZip": false
1314
},
1415
"id": "1-lesson",
1516
"filepath": "1-lesson/content.md",

packages/astro/src/default/utils/__snapshots__/single-part-and-lesson-no-chapter.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"i18n": {
2121
"mocked": "default localization"
2222
},
23-
"openInStackBlitz": true
23+
"openInStackBlitz": true,
24+
"downloadAsZip": false
2425
},
2526
"id": "1-lesson",
2627
"filepath": "1-part/1-lesson/content.md",

packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-lesson.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"i18n": {
3131
"mocked": "default localization"
3232
},
33-
"openInStackBlitz": true
33+
"openInStackBlitz": true,
34+
"downloadAsZip": false
3435
},
3536
"id": "1-lesson",
3637
"filepath": "1-part/1-chapter/1-lesson/content.md",

packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-multiple-lessons.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"i18n": {
3131
"mocked": "default localization"
3232
},
33-
"openInStackBlitz": true
33+
"openInStackBlitz": true,
34+
"downloadAsZip": false
3435
},
3536
"id": "1-first",
3637
"filepath": "1-part/1-chapter/1-first/content.md",
@@ -66,7 +67,8 @@
6667
"i18n": {
6768
"mocked": "default localization"
6869
},
69-
"openInStackBlitz": true
70+
"openInStackBlitz": true,
71+
"downloadAsZip": false
7072
},
7173
"id": "2-second",
7274
"filepath": "1-part/1-chapter/2-second/content.md",
@@ -106,7 +108,8 @@
106108
"i18n": {
107109
"mocked": "default localization"
108110
},
109-
"openInStackBlitz": true
111+
"openInStackBlitz": true,
112+
"downloadAsZip": false
110113
},
111114
"id": "3-third",
112115
"filepath": "1-part/1-chapter/3-third/content.md",

packages/astro/src/default/utils/__snapshots__/single-part-multiple-chapters.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@
4848
"i18n": {
4949
"mocked": "default localization"
5050
},
51-
"openInStackBlitz": true
51+
"openInStackBlitz": true,
52+
"downloadAsZip": false
5253
},
5354
"id": "1-first",
5455
"filepath": "1-part/1-chapter/1-first/content.md",
@@ -84,7 +85,8 @@
8485
"i18n": {
8586
"mocked": "default localization"
8687
},
87-
"openInStackBlitz": true
88+
"openInStackBlitz": true,
89+
"downloadAsZip": false
8890
},
8991
"id": "1-second",
9092
"filepath": "1-part/2-chapter/1-second/content.md",
@@ -124,7 +126,8 @@
124126
"i18n": {
125127
"mocked": "default localization"
126128
},
127-
"openInStackBlitz": true
129+
"openInStackBlitz": true,
130+
"downloadAsZip": false
128131
},
129132
"id": "1-third",
130133
"filepath": "1-part/3-chapter/1-third/content.md",

0 commit comments

Comments
 (0)