Skip to content

Commit 1b5daef

Browse files
committed
feat(astro): add "Download lesson as zip" button
1 parent 5a2a64f commit 1b5daef

File tree

22 files changed

+428
-65
lines changed

22 files changed

+428
-65
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.0",
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,19 @@ 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.
417+
<PropertyTable inherited type="DownloadAsZip" />
418+
419+
The `DownloadAsZip` type has the following shape:
420+
421+
```ts
422+
type DownloadAsZip =
423+
| boolean
424+
| { filename?: string }
425+
426+
```
427+
415428
##### `meta`
416429

417430
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
@@ -30,6 +30,7 @@
3030
"playwright": "^1.46.0",
3131
"react": "^18.3.1",
3232
"react-dom": "^18.3.1",
33-
"unocss": "^0.59.4"
33+
"unocss": "^0.59.4",
34+
"unzipper": "^0.12.3"
3435
}
3536
}

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/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.0",
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}.zip`;
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>

0 commit comments

Comments
 (0)