Skip to content

feat(astro): add "Download lesson as zip" button #415

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tutorialkit.dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"dependencies": {
"@tutorialkit/react": "workspace:*",
"@webcontainer/api": "1.2.4",
"@webcontainer/api": "1.5.1",
"classnames": "^2.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ When overriding `TopBar` you can place TutorialKit's default components using fo

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

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

<LanguageSelect />

<slot name="download-button" />

<slot name="open-in-stackblitz-link" />

<slot name="login-button" />
Expand Down
14 changes: 14 additions & 0 deletions docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,20 @@ type TemplateType = "html" | "node" | "angular-cli" | "create-react-app" | "java

```

### `downloadAsZip`
Display a button for downloading the current lesson as `.zip` file. Defaults to `false`.
The default filename is constructed by concatenating folder names of part, chapter and lesson.
<PropertyTable inherited type="DownloadAsZip" />

The `DownloadAsZip` type has the following shape:

```ts
type DownloadAsZip =
| boolean
| { filename?: string }

```

##### `meta`

Configures `<meta>` tags for Open Graph protocole and Twitter.
Expand Down
3 changes: 2 additions & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"playwright": "^1.46.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"unocss": "^0.59.4"
"unocss": "^0.59.4",
"unzipper": "^0.12.3"
}
}
4 changes: 4 additions & 0 deletions e2e/src/components/TopBar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

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

<div class="mr-2">
<slot name="download-button" />
</div>

<div class="mr-2">
<slot name="open-in-stackblitz-link" />
</div>
Expand Down
1 change: 1 addition & 0 deletions e2e/src/content/tutorial/meta.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
type: tutorial
mainCommand: ''
prepareCommands: []
downloadAsZip: true
---
1 change: 1 addition & 0 deletions e2e/test/topbar.override-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ test('developer can override TopBar', async ({ page }) => {
await expect(nav.getByText('Custom Top Bar Mounted')).toBeVisible();

// default elements should also be visible
await expect(nav.getByRole('button', { name: 'Download lesson as zip-file' })).toBeVisible();
await expect(nav.getByRole('button', { name: 'Open in StackBlitz' })).toBeVisible();
await expect(nav.getByRole('button', { name: 'Toggle Theme' })).toBeVisible();
});
78 changes: 78 additions & 0 deletions e2e/test/topbar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/// <reference types="node" />
import { readdirSync, readFileSync, rmSync } from 'node:fs';
import type { Readable } from 'node:stream';
import { test, expect } from '@playwright/test';
import * as unzipper from 'unzipper';
import { theme } from '../../packages/theme/src/theme';

test('user can change theme', async ({ page }) => {
await page.goto('/');

const heading = page.getByRole('heading', { level: 1 });
const html = page.locator('html');

// default light theme
await expect(html).toHaveAttribute('data-theme', 'light');
await expect(heading).toHaveCSS('color', hexToRGB(theme.colors.gray[800]));

await page.getByRole('navigation').getByRole('button', { name: 'Toggle Theme' }).click();

await expect(html).toHaveAttribute('data-theme', 'dark');
await expect(heading).toHaveCSS('color', hexToRGB(theme.colors.gray[200]));
});

test('user can download project as zip', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });

const downloadPromise = page.waitForEvent('download');
await page.getByRole('navigation').getByRole('button', { name: 'Download lesson as zip-file' }).click();

const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('tests-file-tree-allow-edits-disabled.zip');

const stream = await download.createReadStream();
const files = await unzip(stream);

expect(files).toMatchObject({
'./tutorial/file-on-template.js': "export default 'This file is present on template';\n",
'./tutorial/first-level/file.js': "export default 'File in first level';\n",
'./tutorial/first-level/second-level/file.js': "export default 'File in second level';\n",
});

expect(files['./tutorial/index.mjs']).toMatch("import http from 'node:http'");
});

function hexToRGB(hex: string) {
return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
}

async function unzip(stream: Readable) {
await stream.pipe(unzipper.Extract({ path: './downloads' })).promise();

const files = readDirectoryContents('./downloads');
rmSync('./downloads', { recursive: true });

return files.reduce(
(all, current) => ({
...all,
[current.name.replace('/downloads', '')]: current.content,
}),
{},
);
}

function readDirectoryContents(directory: string) {
const files: { name: string; content: string }[] = [];

for (const entry of readdirSync(directory, { withFileTypes: true })) {
const name = `${directory}/${entry.name}`;

if (entry.isFile()) {
files.push({ name, content: readFileSync(name, 'utf-8') });
} else if (entry.isDirectory()) {
files.push(...readDirectoryContents(name));
}
}

return files;
}
2 changes: 1 addition & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@tutorialkit/types": "workspace:*",
"@types/react": "^18.3.3",
"@unocss/reset": "^0.62.2",
"@webcontainer/api": "1.2.4",
"@webcontainer/api": "1.5.1",
"astro": "^4.15.0",
"astro-expressive-code": "^0.35.3",
"chokidar": "3.6.0",
Expand Down
44 changes: 44 additions & 0 deletions packages/astro/src/default/components/DownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { tutorialStore, webcontainer as webcontainerPromise } from './webcontainer.js';

export function DownloadButton() {
return (
<button
title="Download lesson as zip-file"
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"
onClick={onClick}
>
<div className="i-ph-download-simple" />
</button>
);
}

async function onClick() {
const lesson = tutorialStore.lesson;

if (!lesson) {
throw new Error('Missing lesson');
}
Comment on lines +18 to +20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I suppose we are throwing here because this can only happen in development if there's a bug? 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only to make Typescript happy 😄


const webcontainer = await webcontainerPromise;
const data = await webcontainer.export('/home/tutorial', { format: 'zip', excludes: ['node_modules'] });

let filename =
typeof lesson.data.downloadAsZip === 'object'
? lesson.data.downloadAsZip.filename
: [lesson.part?.id, lesson.chapter?.id, lesson.id].filter(Boolean).join('-');

if (!filename.endsWith('.zip')) {
filename += '.zip';
}

const link = document.createElement('a');
link.style.display = 'none';
link.download = filename;
link.href = URL.createObjectURL(new Blob([data], { type: 'application/zip' }));

document.body.appendChild(link);
link.click();

document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}
4 changes: 4 additions & 0 deletions packages/astro/src/default/components/TopBar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<div class="flex flex-1">
<slot name="logo" />
</div>

<div class="mr-2">
<slot name="download-button" />
</div>
<div class="mr-2">
<slot name="open-in-stackblitz-link" />
</div>
Expand Down
6 changes: 5 additions & 1 deletion packages/astro/src/default/components/TopBarWrapper.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@ import { TopBar } from 'tutorialkit:override-components';
import type { Lesson } from '@tutorialkit/types';
import { ThemeSwitch } from './ThemeSwitch';
import { LoginButton } from './LoginButton';
import { DownloadButton } from './DownloadButton';
import { OpenInStackblitzLink } from './OpenInStackblitzLink';
import Logo from './Logo.astro';
import { useAuth } from './setup';

interface Props {
logoLink: string;
openInStackBlitz: Lesson['data']['openInStackBlitz'];
downloadAsZip: Lesson['data']['downloadAsZip'];
}

const { logoLink, openInStackBlitz } = Astro.props;
const { logoLink, openInStackBlitz, downloadAsZip } = Astro.props;
---

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

{downloadAsZip && <DownloadButton client:load transition:persist slot="download-button" />}

{openInStackBlitz && <OpenInStackblitzLink client:load transition:persist slot="open-in-stackblitz-link" />}

<ThemeSwitch client:load transition:persist slot="theme-switch" />
Expand Down
9 changes: 8 additions & 1 deletion packages/astro/src/default/pages/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ meta.description ??= 'A TutorialKit interactive lesson';

<Layout title={title} meta={meta}>
<PageLoadingIndicator />

<div id="previews-container" style="display: none;"></div>

<main class="max-w-full flex flex-col h-full overflow-hidden" data-swap-root>
<TopBarWrapper logoLink={logoLink ?? '/'} openInStackBlitz={lesson.data.openInStackBlitz} />
<TopBarWrapper
logoLink={logoLink ?? '/'}
openInStackBlitz={lesson.data.openInStackBlitz}
downloadAsZip={lesson.data.downloadAsZip}
/>

<MainContainer lesson={lesson} navList={navList} />
</main>
</Layout>
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-first",
"filepath": "1-part/1-chapter/1-first/content.md",
Expand Down Expand Up @@ -106,7 +107,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-second",
"filepath": "2-part/2-chapter/1-second/content.md",
Expand Down Expand Up @@ -146,7 +148,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-third",
"filepath": "3-part/3-chapter/1-third/content.md",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-lesson",
"filepath": "1-lesson/content.md",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-lesson",
"filepath": "1-part/1-lesson/content.md",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-lesson",
"filepath": "1-part/1-chapter/1-lesson/content.md",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-first",
"filepath": "1-part/1-chapter/1-first/content.md",
Expand Down Expand Up @@ -66,7 +67,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "2-second",
"filepath": "1-part/1-chapter/2-second/content.md",
Expand Down Expand Up @@ -106,7 +108,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "3-third",
"filepath": "1-part/1-chapter/3-third/content.md",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-first",
"filepath": "1-part/1-chapter/1-first/content.md",
Expand Down Expand Up @@ -84,7 +85,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-second",
"filepath": "1-part/2-chapter/1-second/content.md",
Expand Down Expand Up @@ -124,7 +126,8 @@
"i18n": {
"mocked": "default localization"
},
"openInStackBlitz": true
"openInStackBlitz": true,
"downloadAsZip": false
},
"id": "1-third",
"filepath": "1-part/3-chapter/1-third/content.md",
Expand Down
Loading