Skip to content

fix: support a base different from / in astro config #92

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 10 commits into from
Jun 25, 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
6 changes: 4 additions & 2 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"dist"
],
"scripts": {
"build": "node ./scripts/build.js"
"build": "node ./scripts/build.js",
"test": "vitest"
},
"dependencies": {
"@astrojs/mdx": "^3.1.1",
Expand Down Expand Up @@ -55,7 +56,8 @@
"esbuild-node-externals": "^1.13.1",
"execa": "^9.2.0",
"typescript": "^5.4.5",
"vite-plugin-inspect": "0.8.4"
"vite-plugin-inspect": "0.8.4",
"vitest": "^1.6.0"
},
"peerDependencies": {
"astro": "^4.10.2"
Expand Down
6 changes: 5 additions & 1 deletion packages/astro/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { cp, rm } from 'node:fs/promises';
import { execa } from 'execa';
import esbuild from 'esbuild';
import glob from 'fast-glob';
import { nodeExternalsPlugin } from 'esbuild-node-externals';

// clean dist
Expand All @@ -15,7 +16,7 @@ execa('tsc', ['--emitDeclarationOnly', '--project', './tsconfig.build.json'], {
});

// build with esbuild
esbuild.build({
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
tsconfig: './tsconfig.build.json',
Expand All @@ -34,3 +35,6 @@ if (existsSync('./dist/default')) {

// copy default folder unmodified
await cp('./src/default', './dist/default', { recursive: true });

// remove test files
await glob('./dist/default/**/*.spec.ts').then((testFiles) => Promise.all(testFiles.map((testFile) => rm(testFile))));
3 changes: 2 additions & 1 deletion packages/astro/src/default/components/Logo.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import fs from 'node:fs';
import path from 'node:path';
import { joinPaths } from '../utils/url';

const LOGO_EXTENSIONS = ['svg', 'png', 'jpeg', 'jpg'];

Expand All @@ -16,7 +17,7 @@ function readLogoFile(logoPrefix: string) {
const exists = fs.existsSync(path.join('public', logoFilename));

if (exists) {
logo = `/${logoFilename}`;
logo = joinPaths(import.meta.env.BASE_URL, logoFilename);
break;
}
}
Expand Down
7 changes: 6 additions & 1 deletion packages/astro/src/default/components/webcontainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useAuth } from './setup.js';

import { TutorialStore } from '@tutorialkit/runtime';
import { auth, WebContainer } from '@webcontainer/api';
import { joinPaths } from '../utils/url.js';

interface WebContainerContext {
useAuth: boolean;
Expand All @@ -24,7 +25,11 @@ if (!import.meta.env.SSR) {
});
}

export const tutorialStore = new TutorialStore({ webcontainer, useAuth });
export const tutorialStore = new TutorialStore({
webcontainer,
useAuth,
basePathname: joinPaths(import.meta.env.BASE_URL, '/'),
});

export async function login() {
auth.startAuthFlow({ popup: true });
Expand Down
4 changes: 3 additions & 1 deletion packages/astro/src/default/layouts/Layout.astro
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
---
import { ViewTransitions } from 'astro:transitions';
import { joinPaths } from '../utils/url';

interface Props {
title: string;
}

const { title } = Astro.props;
const baseURL = import.meta.env.BASE_URL;
---

<!doctype html>
Expand All @@ -16,7 +18,7 @@ const { title } = Astro.props;
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href={joinPaths(baseURL, '/favicon.svg')} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/default/pages/index.astro
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
import { getTutorial } from '../utils/content';
import { joinPaths } from '../utils/url';

const tutorial = await getTutorial();

const part = tutorial.parts[tutorial.firstPartId!];
const chapter = part.chapters[part?.firstChapterId!];
const lesson = chapter.lessons[chapter?.firstLessonId!];

const redirect = `/${part.slug}/${chapter.slug}/${lesson.slug}`;
const redirect = joinPaths(import.meta.env.BASE_URL, `/${part.slug}/${chapter.slug}/${lesson.slug}`);
---

<!doctype html>
Expand Down
7 changes: 5 additions & 2 deletions packages/astro/src/default/utils/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getCollection } from 'astro:content';
import glob from 'fast-glob';
import path from 'node:path';
import { logger } from './logger';
import { joinPaths } from './url';

const CONTENT_DIR = path.join(process.cwd(), 'src/content/tutorial');

Expand Down Expand Up @@ -226,6 +227,8 @@ export async function getTutorial(): Promise<Tutorial> {
return 0;
});

const baseURL = import.meta.env.BASE_URL;

// now we link all lessons together
for (const [i, lesson] of lessons.entries()) {
const prevLesson = i > 0 ? lessons.at(i - 1) : undefined;
Expand All @@ -248,7 +251,7 @@ export async function getTutorial(): Promise<Tutorial> {

lesson.prev = {
title: prevLesson.data.title,
href: `/${partSlug}/${chapterSlug}/${prevLesson.slug}`,
href: joinPaths(baseURL, `/${partSlug}/${chapterSlug}/${prevLesson.slug}`),
};
}

Expand All @@ -258,7 +261,7 @@ export async function getTutorial(): Promise<Tutorial> {

lesson.next = {
title: nextLesson.data.title,
href: `/${partSlug}/${chapterSlug}/${nextLesson.slug}`,
href: joinPaths(baseURL, `/${partSlug}/${chapterSlug}/${nextLesson.slug}`),
};
}
}
Expand Down
5 changes: 3 additions & 2 deletions packages/astro/src/default/utils/nav.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Tutorial, NavList } from '@tutorialkit/types';
import { joinPaths } from './url';

export function generateNavigationList(tutorial: Tutorial): NavList {
export function generateNavigationList(tutorial: Tutorial, baseURL: string): NavList {
return objectToSortedArray(tutorial.parts).map((part) => {
return {
id: part.id,
Expand All @@ -13,7 +14,7 @@ export function generateNavigationList(tutorial: Tutorial): NavList {
return {
id: lesson.id,
title: lesson.data.title,
href: `/${part.slug}/${chapter.slug}/${lesson.slug}`,
href: joinPaths(baseURL, `/${part.slug}/${chapter.slug}/${lesson.slug}`),
};
}),
};
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/default/utils/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function generateStaticRoutes() {
},
props: {
logoLink: tutorial.logoLink,
navList: generateNavigationList(tutorial),
navList: generateNavigationList(tutorial, import.meta.env.BASE_URL),
title: `${part.data.title} / ${chapter.data.title} / ${lesson.data.title}`,
lesson: lesson as Lesson<AstroComponentFactory>,
},
Expand Down
36 changes: 36 additions & 0 deletions packages/astro/src/default/utils/url.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { it, describe, expect } from 'vitest';
import { joinPaths } from './url';

describe('joinPaths', () => {
it('should join paths', () => {
expect(joinPaths('/a', 'b')).toBe('/a/b');
expect(joinPaths('/a/', 'b')).toBe('/a/b');
expect(joinPaths('/a', '/b')).toBe('/a/b');
expect(joinPaths('/a/', '/b')).toBe('/a/b');
expect(joinPaths('/', '/')).toBe('/');
});

it('should join multiple paths', () => {
expect(joinPaths('/a', 'b', '/c')).toBe('/a/b/c');
expect(joinPaths('/a', '/b', 'c')).toBe('/a/b/c');
expect(joinPaths('/a/', '/b', '/c')).toBe('/a/b/c');
});

it('should join paths with empty strings', () => {
expect(joinPaths('', 'b')).toBe('/b');
expect(joinPaths('/a', '')).toBe('/a');
expect(joinPaths('', '')).toBe('/');
});

it('should join paths with empty strings in the middle', () => {
expect(joinPaths('/a', '', 'b')).toBe('/a/b');
expect(joinPaths('/a', '', '', 'b')).toBe('/a/b');
});

it('should keep trailing slashes', () => {
expect(joinPaths('/a/')).toBe('/a/');
expect(joinPaths('/a/', 'b/')).toBe('/a/b/');
expect(joinPaths('/a/', 'b/', '/c')).toBe('/a/b/c');
expect(joinPaths('/a/', 'b/', '/c/')).toBe('/a/b/c/');
});
});
22 changes: 22 additions & 0 deletions packages/astro/src/default/utils/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function joinPaths(basePath: string, ...paths: string[]): string {
let result = basePath || '/';

for (const subpath of paths) {
if (subpath.length === 0) {
continue;
}

const resultEndsWithSlash = result.endsWith('/');
const subpathStartsWithSlash = subpath.startsWith('/');

if (resultEndsWithSlash && subpathStartsWithSlash) {
result += subpath.slice(1);
} else if (resultEndsWithSlash || subpathStartsWithSlash) {
result += subpath;
} else {
result += `/${subpath}`;
}
}

return result;
}
10 changes: 10 additions & 0 deletions packages/runtime/src/lesson-files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ describe('LessonFilesFetcher', () => {
expect(fetchSpy).toHaveBeenCalledWith('/template-default.json', expect.anything());
});

test('getLessonTemplate should fetch at a different pathname if the fetcher is configured to use a different base', async () => {
fetchBody = { 'a.txt': 'content' };

const fetcher = new LessonFilesFetcher('/foo');
const files = await fetcher.getLessonTemplate({ data: { template: 'default' } } as any);

expect(files).toEqual({ 'a.txt': 'content' });
expect(fetchSpy).toHaveBeenCalledWith('/foo/template-default.json', expect.anything());
});

test('getLessonFiles should fetch files', async () => {
fetchBody = { 'a.txt': 'content' };

Expand Down
10 changes: 8 additions & 2 deletions packages/runtime/src/lesson-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export class LessonFilesFetcher {
private _templateLoadTask?: Task<Files>;
private _templateLoaded: string | undefined;

constructor(private _basePathname: string = '/') {
if (!this._basePathname.endsWith('/')) {
this._basePathname = this._basePathname + '/';
}
}

async invalidate(filesRef: string): Promise<InvalidationResult> {
if (!this._map.has(filesRef)) {
return { type: 'none' };
Expand Down Expand Up @@ -63,7 +69,7 @@ export class LessonFilesFetcher {
this._templateLoadTask?.cancel();

const task = newTask(async (signal) => {
const response = await fetch(`/${templatePathname}`, { signal });
const response = await fetch(`${this._basePathname}${templatePathname}`, { signal });

if (!response.ok) {
throw new Error(`Failed to fetch: status ${response.status}`);
Expand Down Expand Up @@ -106,7 +112,7 @@ export class LessonFilesFetcher {

while (true) {
try {
const response = await fetch(`/${pathname}`);
const response = await fetch(`${this._basePathname}${pathname}`);

if (!response.ok) {
throw new Error(`Failed to fetch ${pathname}: ${response.status} ${response.statusText}`);
Expand Down
14 changes: 12 additions & 2 deletions packages/runtime/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ import { TerminalStore } from './terminal.js';

interface StoreOptions {
webcontainer: Promise<WebContainer>;

/**
* Whether or not authentication is used for the WebContainer API.
*/
useAuth: boolean;

/**
* The base path to use when fetching files.
*/
basePathname?: string;
}

export class TutorialStore {
Expand All @@ -26,7 +35,7 @@ export class TutorialStore {
private _terminalStore: TerminalStore;

private _stepController = new StepsController();
private _lessonFilesFetcher = new LessonFilesFetcher();
private _lessonFilesFetcher: LessonFilesFetcher;
private _lessonTask: Task<unknown> | undefined;
private _lesson: Lesson | undefined;
private _ref: number = 1;
Expand All @@ -42,9 +51,10 @@ export class TutorialStore {
*/
readonly lessonFullyLoaded = atom<boolean>(false);

constructor({ useAuth, webcontainer }: StoreOptions) {
constructor({ useAuth, webcontainer, basePathname }: StoreOptions) {
this._webcontainer = webcontainer;
this._editorStore = new EditorStore();
this._lessonFilesFetcher = new LessonFilesFetcher(basePathname);
this._previewsStore = new PreviewsStore(this._webcontainer);
this._terminalStore = new TerminalStore(this._webcontainer, useAuth);
this._runner = new TutorialRunner(this._webcontainer, this._terminalStore, this._stepController);
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.