Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.0",
"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
13 changes: 13 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,19 @@ type TemplateType = "html" | "node" | "angular-cli" | "create-react-app" | "java

```

### `downloadAsZip`
Display a button for downloading the current lesson as `.zip` file.
<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 @@ -30,6 +30,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/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.0",
"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}.zip`;

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>
2 changes: 2 additions & 0 deletions packages/astro/src/default/utils/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export async function getTutorial(): Promise<Tutorial> {
tutorialMetaData.template ??= 'default';
tutorialMetaData.i18n = Object.assign({ ...DEFAULT_LOCALIZATION }, tutorialMetaData.i18n);
tutorialMetaData.openInStackBlitz ??= true;
tutorialMetaData.downloadAsZip ??= true;

_tutorial.logoLink = data.logoLink;
} else if (type === 'part') {
Expand Down Expand Up @@ -248,6 +249,7 @@ export async function getTutorial(): Promise<Tutorial> {
'meta',
'editPageLink',
'openInStackBlitz',
'downloadAsZip',
'filesystem',
],
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ exports[`create and eject a project 1`] = `
"public/logo.svg",
"src",
"src/components",
"src/components/DownloadButton.tsx",
"src/components/HeadTags.astro",
"src/components/LoginButton.tsx",
"src/components/Logo.astro",
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"@replit/codemirror-lang-svelte": "^6.0.0",
"@tutorialkit/runtime": "workspace:*",
"@tutorialkit/theme": "workspace:*",
"@webcontainer/api": "1.2.4",
"@webcontainer/api": "1.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
},
"dependencies": {
"@tutorialkit/types": "workspace:*",
"@webcontainer/api": "1.2.4",
"@webcontainer/api": "1.5.0",
"nanostores": "^0.10.3",
"picomatch": "^4.0.2"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
type: chapter
title: The second chapter in part 1
openInStackBlitz: true
downloadAsZip: true
---
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
type: chapter
title: The first chatper in part 2
openInStackBlitz: false
downloadAsZip: false
---
2 changes: 2 additions & 0 deletions packages/template/src/content/tutorial/meta.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ i18n:
openInStackBlitz:
projectTitle: Example Title
projectDescription: Example Description
downloadAsZip:
filename: custom-lesson-name-without-extension
---
2 changes: 1 addition & 1 deletion packages/test-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"private": true,
"devDependencies": {
"@webcontainer/api": "1.2.4",
"@webcontainer/api": "1.5.0",
"typescript": "^5.4.5",
"vitest": "^2.1.1"
}
Expand Down
11 changes: 11 additions & 0 deletions packages/types/src/schemas/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,17 @@ export const webcontainerSchema = commandsSchema.extend({
])
.optional()
.describe('Display a link for opening current lesson in StackBlitz.'),
downloadAsZip: z
.union([
// `false` for disabling the button
z.boolean(),

z.strictObject({
filename: z.string(),
}),
])
.optional()
.describe('Display a button for downloading the current lesson as `.zip` file.'),
});

export const baseSchema = webcontainerSchema.extend({
Expand Down
Loading
Loading