diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index dbf7af7b..ee8b6e1b 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -91,18 +91,32 @@ Configure whether or not the editor should be rendered. If an object is provided ##### `previews` Configure which ports should be used for the previews allowing you to align the behavior with your demo application's dev server setup. If not specified, the lowest port will be used. -You can optionally provide these as an array of tuples where the first element is the port number and the second is the title of the preview, or as an object. The `Preview` type has the following shape: ```ts -type Preview = string +type Preview = + | number + | string | [port: number, title: string] - | { port: number, title: string } + | [port: number, title: string, pathname: string] + | { port: number, title: string, pathname?: string } ``` +Example value: + +```yaml +previews: + - 3000 # Preview is on :3000/ + - "3001/docs" # Preview is on :3001/docs/ + - [3002, "Dev Server"] # Preview is on :3002/. Displayed title is "Dev Server". + - [3003, "Dev Server", "/docs"] # Preview is on :3003/docs/. Displayed title is "Dev Server". + - { port: 3004, title: "Dev Server" } # Preview is on :3004/. Displayed title is "Dev Server". + - { port: 3005, title: "Dev Server", pathname: "/docs" } # Preview is on :3005/docs/. Displayed title is "Dev Server". +``` + ##### `mainCommand` The main command to be executed. This command will run after the `prepareCommands`. diff --git a/packages/runtime/src/store/previews.spec.ts b/packages/runtime/src/store/previews.spec.ts new file mode 100644 index 00000000..8540fa63 --- /dev/null +++ b/packages/runtime/src/store/previews.spec.ts @@ -0,0 +1,78 @@ +import { assert, expect, test } from 'vitest'; +import { PreviewsStore } from './previews.js'; +import type { PortListener, WebContainer } from '@webcontainer/api'; + +test("preview is set ready on webcontainer's event", async () => { + const { store, emit } = await getStore(); + store.setPreviews([3000]); + + assert(store.previews.value); + expect(store.previews.value[0].ready).toBe(false); + + emit(3000, 'open', 'https://localhost'); + + expect(store.previews.value![0].ready).toBe(true); +}); + +test('preview is not set ready when different port is ready', async () => { + const { store, emit } = await getStore(); + store.setPreviews([3000]); + + assert(store.previews.value); + expect(store.previews.value[0].ready).toBe(false); + + emit(3001, 'open', 'https://localhost'); + + expect(store.previews.value[0].ready).toBe(false); +}); + +test('marks multiple preview infos ready', async () => { + const { store, emit } = await getStore(); + store.setPreviews([ + { port: 3000, title: 'Dev' }, + { port: 3000, title: 'Docs', pathname: '/docs' }, + ]); + + assert(store.previews.value); + expect(store.previews.value).toHaveLength(2); + + expect(store.previews.value[0].ready).toBe(false); + expect(store.previews.value[0].pathname).toBe(undefined); + + expect(store.previews.value[1].ready).toBe(false); + expect(store.previews.value[1].pathname).toBe('/docs'); + + emit(3000, 'open', 'https://localhost'); + + expect(store.previews.value[0].ready).toBe(true); + expect(store.previews.value[1].ready).toBe(true); +}); + +async function getStore() { + const listeners: PortListener[] = []; + + const webcontainer: Pick = { + on: (type, listener) => { + if (type === 'port') { + listeners.push(listener as PortListener); + } + + return () => undefined; + }, + }; + + const promise = new Promise((resolve) => { + resolve(webcontainer as WebContainer); + }); + + await promise; + + return { + store: new PreviewsStore(promise), + emit: (...args: Parameters) => { + assert(listeners.length > 0, 'Port listeners were not captured'); + + listeners.forEach((cb) => cb(...args)); + }, + }; +} diff --git a/packages/runtime/src/store/previews.ts b/packages/runtime/src/store/previews.ts index acbf5b5c..6f7175cd 100644 --- a/packages/runtime/src/store/previews.ts +++ b/packages/runtime/src/store/previews.ts @@ -1,10 +1,11 @@ import type { PreviewSchema } from '@tutorialkit/types'; +import type { WebContainer } from '@webcontainer/api'; import { atom } from 'nanostores'; import { PreviewInfo } from '../webcontainer/preview-info.js'; -import type { WebContainer } from '@webcontainer/api'; +import { PortInfo } from '../webcontainer/port-info.js'; export class PreviewsStore { - private _availablePreviews = new Map(); + private _availablePreviews = new Map(); private _previewsLayout: PreviewInfo[] = []; /** @@ -21,18 +22,19 @@ export class PreviewsStore { const webcontainer = await webcontainerPromise; webcontainer.on('port', (port, type, url) => { - let previewInfo = this._availablePreviews.get(port); + let portInfo = this._availablePreviews.get(port); + + if (!portInfo) { + portInfo = new PortInfo(port, url, type === 'open'); - if (!previewInfo) { - previewInfo = new PreviewInfo(port, type === 'open'); - this._availablePreviews.set(port, previewInfo); + this._availablePreviews.set(port, portInfo); } - previewInfo.ready = type === 'open'; - previewInfo.baseUrl = url; + portInfo.ready = type === 'open'; + portInfo.origin = url; if (this._previewsLayout.length === 0) { - this.previews.set([previewInfo]); + this.previews.set([new PreviewInfo({}, portInfo)]); } else { this._previewsLayout = [...this._previewsLayout]; this.previews.set(this._previewsLayout); @@ -55,20 +57,16 @@ export class PreviewsStore { // if the schema is `true`, we just use the default empty array const previews = config === true ? [] : config ?? []; - const previewInfos = previews.map((preview) => { - const info = new PreviewInfo(preview); + const previewInfos = previews.map((previewConfig) => { + const preview = PreviewInfo.parse(previewConfig); + let portInfo = this._availablePreviews.get(preview.port); - let previewInfo = this._availablePreviews.get(info.port); - - if (!previewInfo) { - previewInfo = info; - - this._availablePreviews.set(previewInfo.port, previewInfo); - } else { - previewInfo.title = info.title; + if (!portInfo) { + portInfo = new PortInfo(preview.port); + this._availablePreviews.set(preview.port, portInfo); } - return previewInfo; + return new PreviewInfo(preview, portInfo); }); let areDifferent = previewInfos.length != this._previewsLayout.length; diff --git a/packages/runtime/src/webcontainer/port-info.ts b/packages/runtime/src/webcontainer/port-info.ts new file mode 100644 index 00000000..47093f0d --- /dev/null +++ b/packages/runtime/src/webcontainer/port-info.ts @@ -0,0 +1,7 @@ +export class PortInfo { + constructor( + readonly port: number, + public origin?: string, + public ready: boolean = false, + ) {} +} diff --git a/packages/runtime/src/webcontainer/preview-info.spec.ts b/packages/runtime/src/webcontainer/preview-info.spec.ts index 6dfe3e4b..0b008fbc 100644 --- a/packages/runtime/src/webcontainer/preview-info.spec.ts +++ b/packages/runtime/src/webcontainer/preview-info.spec.ts @@ -1,80 +1,120 @@ import { describe, it, expect } from 'vitest'; import { PreviewInfo } from './preview-info.js'; +import { PortInfo } from './port-info.js'; describe('PreviewInfo', () => { - it('should accept a port', () => { - const previewInfo = new PreviewInfo(3000); + it('should accept a number for port', () => { + const previewInfo = PreviewInfo.parse(3000); expect(previewInfo.port).toBe(3000); + expect(previewInfo.title).toBe(undefined); + expect(previewInfo.pathname).toBe(undefined); + }); + + it('should accept a string for port and pathname', () => { + const previewInfo = PreviewInfo.parse('3000/some/nested/path'); + + expect(previewInfo.port).toBe(3000); + expect(previewInfo.pathname).toBe('some/nested/path'); + expect(previewInfo.title).toBe(undefined); }); it('should accept a tuple of [port, title]', () => { - const previewInfo = new PreviewInfo([3000, 'Local server']); + const previewInfo = PreviewInfo.parse([3000, 'Local server']); expect(previewInfo.port).toBe(3000); expect(previewInfo.title).toBe('Local server'); + expect(previewInfo.pathname).toBe(undefined); + }); + + it('should accept a tuple of [port, title, pathname]', () => { + const previewInfo = PreviewInfo.parse([3000, 'Local server', '/docs']); + + expect(previewInfo.port).toBe(3000); + expect(previewInfo.title).toBe('Local server'); + expect(previewInfo.pathname).toBe('/docs'); }); it('should accept an object with { port, title }', () => { - const previewInfo = new PreviewInfo({ port: 3000, title: 'Local server' }); + const previewInfo = PreviewInfo.parse({ port: 3000, title: 'Local server' }); expect(previewInfo.port).toBe(3000); expect(previewInfo.title).toBe('Local server'); + expect(previewInfo.pathname).toBe(undefined); + }); + + it('should accept an object with { port, title, pathname }', () => { + const previewInfo = PreviewInfo.parse({ port: 3000, title: 'Local server', pathname: '/docs' }); + + expect(previewInfo.port).toBe(3000); + expect(previewInfo.title).toBe('Local server'); + expect(previewInfo.pathname).toBe('/docs'); }); it('should not be ready by default', () => { - const previewInfo = new PreviewInfo(3000); + const previewInfo = new PreviewInfo({}, new PortInfo(3000)); expect(previewInfo.ready).toBe(false); }); it('should be ready if explicitly set', () => { - const previewInfo = new PreviewInfo(3000, true); + const previewInfo = new PreviewInfo({}, new PortInfo(3000, undefined, true)); expect(previewInfo.ready).toBe(true); }); it('should not be ready if explicitly set', () => { - const previewInfo = new PreviewInfo(3000, false); + const previewInfo = new PreviewInfo({}, new PortInfo(3000, undefined, false)); expect(previewInfo.ready).toBe(false); }); it('should have a url with a custom pathname and baseUrl', () => { - const previewInfo = new PreviewInfo(3000); - previewInfo.baseUrl = 'https://example.com'; - previewInfo.pathname = '/foo'; + const parsed = PreviewInfo.parse('3000/foo'); + const previewInfo = new PreviewInfo(parsed, new PortInfo(parsed.port)); + previewInfo.portInfo.origin = 'https://example.com'; expect(previewInfo.url).toBe('https://example.com/foo'); }); it('should be equal to another preview info with the same port and title', () => { - const a = new PreviewInfo(3000); - const b = new PreviewInfo(3000); + const a = new PreviewInfo({}, new PortInfo(3000)); + const b = new PreviewInfo({}, new PortInfo(3000)); expect(PreviewInfo.equals(a, b)).toBe(true); }); it('should not be equal to another preview info with a different port', () => { - const a = new PreviewInfo(3000); - const b = new PreviewInfo(4000); + const a = new PreviewInfo({}, new PortInfo(3000)); + const b = new PreviewInfo({}, new PortInfo(4000)); expect(PreviewInfo.equals(a, b)).toBe(false); }); it('should not be equal to another preview info with a different title', () => { - const a = new PreviewInfo([3000, 'Local server']); - const b = new PreviewInfo([3000, 'Remote server']); + const parsed = { + a: PreviewInfo.parse([3000, 'Local server']), + b: PreviewInfo.parse([3000, 'Remote server']), + }; + + const a = new PreviewInfo(parsed.a, new PortInfo(parsed.a.port)); + const b = new PreviewInfo(parsed.b, new PortInfo(parsed.b.port)); expect(PreviewInfo.equals(a, b)).toBe(false); }); it('should not be equal to another preview info with a different pathname', () => { - const a = new PreviewInfo(3000); - const b = new PreviewInfo(3000); + const parsed = { + a: PreviewInfo.parse(3000), + b: PreviewInfo.parse('3000/b'), + c: PreviewInfo.parse('3000/c'), + }; - a.pathname = '/foo'; + const a = new PreviewInfo(parsed.a, new PortInfo(parsed.a.port)); + const b = new PreviewInfo(parsed.b, new PortInfo(parsed.b.port)); + const c = new PreviewInfo(parsed.c, new PortInfo(parsed.c.port)); expect(PreviewInfo.equals(a, b)).toBe(false); + expect(PreviewInfo.equals(b, c)).toBe(false); }); }); diff --git a/packages/runtime/src/webcontainer/preview-info.ts b/packages/runtime/src/webcontainer/preview-info.ts index 55c781e7..f71bfe4c 100644 --- a/packages/runtime/src/webcontainer/preview-info.ts +++ b/packages/runtime/src/webcontainer/preview-info.ts @@ -1,10 +1,9 @@ import type { PreviewSchema } from '@tutorialkit/types'; +import { PortInfo } from './port-info.js'; export class PreviewInfo { - port: number; - ready: boolean; + readonly portInfo: PortInfo; title?: string; - baseUrl?: string; pathname?: string; get url(): string | undefined { @@ -15,21 +14,53 @@ export class PreviewInfo { return undefined; } - constructor(preview: Exclude[0], ready?: boolean) { + get port() { + return this.portInfo.port; + } + + get baseUrl() { + return this.portInfo.origin; + } + + get ready() { + return this.portInfo.ready; + } + + constructor(preview: Omit, portInfo: PortInfo) { + this.title = preview.title; + this.pathname = preview.pathname; + this.portInfo = portInfo; + } + + static parse(preview: Exclude[0]): Preview { if (typeof preview === 'number') { - this.port = preview; + return { + port: preview, + }; + } else if (typeof preview === 'string') { + const [port, ...rest] = preview.split('/'); + return { + port: parseInt(port), + pathname: rest.join('/'), + }; } else if (Array.isArray(preview)) { - this.port = preview[0]; - this.title = preview[1]; + return { + port: preview[0], + title: preview[1], + pathname: preview[2], + }; } else { - this.port = preview.port; - this.title = preview.title; + return preview; } - - this.ready = !!ready; } static equals(a: PreviewInfo, b: PreviewInfo) { - return a.port === b.port && a.pathname === b.pathname && a.title === b.title; + return a.portInfo.port === b.portInfo.port && a.pathname === b.pathname && a.title === b.title; } } + +interface Preview { + port: number; + pathname?: string; + title?: string; +} diff --git a/packages/template/src/content/tutorial/1-basics/1-introduction/2-foo/content.mdx b/packages/template/src/content/tutorial/1-basics/1-introduction/2-foo/content.mdx index b60458a2..614725e1 100644 --- a/packages/template/src/content/tutorial/1-basics/1-introduction/2-foo/content.mdx +++ b/packages/template/src/content/tutorial/1-basics/1-introduction/2-foo/content.mdx @@ -4,9 +4,10 @@ title: Foo from part 1 slug: foo focus: /src/index.html previews: - - [8080, 'Main Page'] + - { title: 'Main Page', port: 8080, pathname: '/src'} - [1, 'Test Runner'] - - [2, 'Bar'] + - '2/some/custom/pathname' + - '2/another/pathname' terminal: panels: 'terminal' editPageLink: 'https://tutorialkit.dev' diff --git a/packages/template/src/templates/default/src/index.js b/packages/template/src/templates/default/src/index.js index b942c5c9..1c72cdef 100644 --- a/packages/template/src/templates/default/src/index.js +++ b/packages/template/src/templates/default/src/index.js @@ -15,7 +15,7 @@ createServer((_req, res) => { `); }).listen(1); -createServer((_req, res) => res.end('Server 2')).listen(2); +createServer((req, res) => res.end(`Server 2\n${req.method} ${req.url}`)).listen(2); servor({ root: 'src/', diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index 06d36ec5..56c56c2c 100644 --- a/packages/types/src/schemas/common.ts +++ b/packages/types/src/schemas/common.ts @@ -37,12 +37,17 @@ export const previewSchema = z.union([ // a single number, the port for the preview z.number(), - // a tuple, the port followed by a title + // a string, the port and pathname + z.string(), + + // a tuple, the port followed by a title and optional pathname z.tuple([z.number(), z.string()]), + z.tuple([z.number(), z.string(), z.string()]), z.strictObject({ port: z.number().describe('Port number of the preview.'), title: z.string().describe('Title of the preview.'), + pathname: z.string().optional().describe('Pathname of the preview URL.'), }), ]) .array(),