Skip to content

Commit 0e4e9e4

Browse files
committed
feat: add template.visibleFiles option
1 parent eb2521f commit 0e4e9e4

File tree

11 files changed

+118
-10
lines changed

11 files changed

+118
-10
lines changed

Diff for: docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx

+21
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ To get a better understanding of how the code you create in each lesson results
9191

9292
The `_solution` folder contains the code for the lesson in its solved state. This is the code that the user will see when they click the "Solve" button in the tutorial app. The folder usually contains the same files as the `_files` folder, but with the code filled in. It may also include additional files.
9393

94+
### File Visibility
95+
96+
Editor's files are resolved by three steps. Each step overrides previous one:
97+
98+
1. Display files matching `template.visibleFiles` (lowest priority)
99+
2. Display files from `_files` directory
100+
3. When solution is revealed, display files from `_solution` directory. (highest priority)
101+
94102
## Code templates
95103

96104
For the code to run in the preview, it must be an actual, working application, including a dev server and any necessary configuration files. Usually, it's not practical to include this kind of boilerplate files in a lesson content (and copy it for every further lesson!). Instead, you can use the `template` feature of TutorialKit to provide a base project that will be used for all lessons.
@@ -110,6 +118,19 @@ template: my-advanced-template
110118

111119
This declaration will make TutorialKit use the `src/templates/my-advanced-template` directory as the base for the lesson.
112120

121+
By default files in template are not shown in the code editor.
122+
To make them visible, you can use `visibleFiles` option.
123+
This can reduce repetition when you want to show same files visible in multiple lessons.
124+
125+
```markdown {5}
126+
---
127+
title: Advanced Topics
128+
template:
129+
name: my-advanced-template
130+
visibleFiles: ['src/index.js', '**/utils/**']
131+
---
132+
```
133+
113134
If you start having a lot of templates and they all share some files, you can create a shared template that they all extend. This way, you can keep the shared files in one place and avoid duplication. To do that, you need to specify the `extends` property in the template's `.tk-config.json` file:
114135

115136
```json

Diff for: packages/astro/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"kleur": "4.1.5",
5151
"mdast-util-directive": "^3.0.0",
5252
"mdast-util-to-markdown": "^2.1.0",
53+
"micromatch": "^4.0.7",
5354
"nanostores": "^0.10.3",
5455
"react": "^18.3.1",
5556
"react-dom": "^18.3.1",
@@ -62,6 +63,7 @@
6263
"devDependencies": {
6364
"@tutorialkit/types": "workspace:*",
6465
"@types/mdast": "^4.0.4",
66+
"@types/micromatch": "^4.0.9",
6567
"esbuild": "^0.20.2",
6668
"esbuild-node-externals": "^1.13.1",
6769
"execa": "^9.2.0",

Diff for: packages/astro/src/default/utils/content.ts

+29-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import { folderPathToFilesRef, interpolateString } from '@tutorialkit/types';
1111
import { getCollection } from 'astro:content';
1212
import glob from 'fast-glob';
13+
import mm from 'micromatch';
1314
import path from 'node:path';
1415
import { IGNORED_FILES } from './constants';
1516
import { DEFAULT_LOCALIZATION } from './content/default-localization';
@@ -18,6 +19,7 @@ import { logger } from './logger';
1819
import { joinPaths } from './url';
1920

2021
const CONTENT_DIR = path.join(process.cwd(), 'src/content/tutorial');
22+
const TEMPLATES_DIR = path.join(process.cwd(), 'src/templates');
2123

2224
export async function getTutorial(): Promise<Tutorial> {
2325
const collection = sortCollection(await getCollection('tutorial'));
@@ -262,6 +264,22 @@ export async function getTutorial(): Promise<Tutorial> {
262264
),
263265
};
264266

267+
if (lesson.data.template && typeof lesson.data.template !== 'string' && lesson.data.template.visibleFiles?.length) {
268+
const templateFilesRef = await getFilesRefList(lesson.data.template.name, TEMPLATES_DIR);
269+
270+
for (const filename of templateFilesRef[1]) {
271+
if (lesson.files[1].includes(filename)) {
272+
continue;
273+
}
274+
275+
if (mm.isMatch(filename, lesson.data.template.visibleFiles, { format: formatTemplateFile })) {
276+
lesson.files[1].push(filename);
277+
}
278+
}
279+
280+
lesson.files[1].sort();
281+
}
282+
265283
if (prevLesson) {
266284
const partSlug = _tutorial.parts[prevLesson.part.id].slug;
267285
const chapterSlug = _tutorial.parts[prevLesson.part.id].chapters[prevLesson.chapter.id].slug;
@@ -330,8 +348,8 @@ function getSlug(entry: CollectionEntryTutorial) {
330348
return slug;
331349
}
332350

333-
async function getFilesRefList(pathToFolder: string): Promise<FilesRefList> {
334-
const root = path.join(CONTENT_DIR, pathToFolder);
351+
async function getFilesRefList(pathToFolder: string, base = CONTENT_DIR): Promise<FilesRefList> {
352+
const root = path.join(base, pathToFolder);
335353

336354
const filePaths = (
337355
await glob(`${glob.convertPathToPattern(root)}/**/*`, {
@@ -348,6 +366,15 @@ async function getFilesRefList(pathToFolder: string): Promise<FilesRefList> {
348366
return [filesRef, filePaths];
349367
}
350368

369+
function formatTemplateFile(filename: string) {
370+
// compare files without leading "/" so that patterns like ["src/index.js"] match "/src/index.js"
371+
if (filename.startsWith('/')) {
372+
return filename.substring(1);
373+
}
374+
375+
return filename;
376+
}
377+
351378
interface CollectionEntryTutorial {
352379
id: string;
353380
slug: string;

Diff for: packages/cli/src/commands/eject/index.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@ interface PackageJson {
1818
}
1919

2020
const TUTORIALKIT_VERSION = pkg.version;
21-
const REQUIRED_DEPENDENCIES = ['@tutorialkit/runtime', '@webcontainer/api', 'nanostores', '@nanostores/react', 'kleur'];
21+
const REQUIRED_DEPENDENCIES = [
22+
'@tutorialkit/runtime',
23+
'@webcontainer/api',
24+
'nanostores',
25+
'@nanostores/react',
26+
'kleur',
27+
'micromatch',
28+
'@types/micromatch',
29+
];
2230

2331
export function ejectRoutes(flags: Arguments) {
2432
if (flags._[1] === 'help' || flags.help || flags.h) {
@@ -104,6 +112,7 @@ async function _eject(flags: EjectOptions) {
104112
for (const dep of REQUIRED_DEPENDENCIES) {
105113
if (!(dep in pkgJson.dependencies) && !(dep in pkgJson.devDependencies)) {
106114
pkgJson.dependencies[dep] = astroIntegrationPkgJson.dependencies[dep];
115+
pkgJson.devDependencies[dep] = astroIntegrationPkgJson.devDependencies[dep];
107116

108117
newDependencies.push(dep);
109118
}

Diff for: packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap

+2
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ exports[`create a project 1`] = `
7575
"src/templates/default/package.json",
7676
"src/templates/default/src",
7777
"src/templates/default/src/index.js",
78+
"src/templates/default/src/template-only-file.js",
7879
"src/templates/vite-app",
7980
"src/templates/vite-app-2",
8081
"src/templates/vite-app-2/.tk-config.json",
@@ -233,6 +234,7 @@ exports[`create and eject a project 1`] = `
233234
"src/templates/default/package.json",
234235
"src/templates/default/src",
235236
"src/templates/default/src/index.js",
237+
"src/templates/default/src/template-only-file.js",
236238
"src/templates/vite-app",
237239
"src/templates/vite-app-2",
238240
"src/templates/vite-app-2/.tk-config.json",

Diff for: packages/runtime/src/store/index.ts

+22-6
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,18 @@ export class TutorialStore {
4242
private _ref: number = 1;
4343
private _themeRef = atom(1);
4444

45+
/** Files from lesson's `_files` directory */
4546
private _lessonFiles: Files | undefined;
47+
48+
/** Files from lesson's `_solution` directory */
4649
private _lessonSolution: Files | undefined;
50+
51+
/** All files from `template` directory */
4752
private _lessonTemplate: Files | undefined;
4853

54+
/** Files from `template` directory that match `template.visibleFiles` patterns */
55+
private _visibleTemplateFiles: Files | undefined;
56+
4957
/**
5058
* Whether or not the current lesson is fully loaded in WebContainer
5159
* and in every stores.
@@ -165,15 +173,17 @@ export class TutorialStore {
165173

166174
signal.throwIfAborted();
167175

168-
this._lessonTemplate = template;
169176
this._lessonFiles = files;
170177
this._lessonSolution = solution;
178+
this._lessonTemplate = template;
179+
this._visibleTemplateFiles = filterEntries(template, lesson.files[1]);
171180

172-
this._editorStore.setDocuments(files);
181+
const editorFiles = { ...this._visibleTemplateFiles, ...this._lessonFiles };
182+
this._editorStore.setDocuments(editorFiles);
173183

174184
if (lesson.data.focus === undefined) {
175185
this._editorStore.setSelectedFile(undefined);
176-
} else if (files[lesson.data.focus] !== undefined) {
186+
} else if (editorFiles[lesson.data.focus] !== undefined) {
177187
this._editorStore.setSelectedFile(lesson.data.focus);
178188
}
179189

@@ -279,8 +289,10 @@ export class TutorialStore {
279289
return;
280290
}
281291

282-
this._editorStore.setDocuments(this._lessonFiles);
283-
this._runner.updateFiles(this._lessonFiles);
292+
const files = { ...this._visibleTemplateFiles, ...this._lessonFiles };
293+
294+
this._editorStore.setDocuments(files);
295+
this._runner.updateFiles(files);
284296
}
285297

286298
solve() {
@@ -290,7 +302,7 @@ export class TutorialStore {
290302
return;
291303
}
292304

293-
const files = { ...this._lessonFiles, ...this._lessonSolution };
305+
const files = { ...this._visibleTemplateFiles, ...this._lessonFiles, ...this._lessonSolution };
294306

295307
this._editorStore.setDocuments(files);
296308
this._runner.updateFiles(files);
@@ -353,3 +365,7 @@ export class TutorialStore {
353365
this._themeRef.set(this._themeRef.get() + 1);
354366
}
355367
}
368+
369+
function filterEntries<T extends object>(obj: T, filter: string[]) {
370+
return Object.fromEntries(Object.entries(obj).filter(([entry]) => filter.includes(entry)));
371+
}

Diff for: packages/template/src/content/tutorial/1-basics/1-introduction/1-welcome/content.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
type: lesson
33
title: Welcome to TutorialKit
4-
focus: /src/index.js
4+
focus: /src/template-only-file.js
55
previews: [8080]
66
mainCommand: ['node -e setTimeout(()=>{},10_000)', 'Running dev server']
77
prepareCommands:
@@ -13,6 +13,7 @@ terminal:
1313
panels: ['terminal', 'output']
1414
template:
1515
name: default
16+
visibleFiles: ['src/template-only-file.js']
1617
---
1718

1819
# Kitchen Sink [Heading 1]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default 'This file is only present in template';

Diff for: packages/types/src/schemas/common.spec.ts

+10
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,16 @@ describe('webcontainerSchema', () => {
358358
}).not.toThrow();
359359
});
360360
it('should allow specifying the template by object type', () => {
361+
expect(() => {
362+
webcontainerSchema.parse({
363+
template: {
364+
name: 'default',
365+
visibleFiles: ['**/fixture.json', '*/tests/*'],
366+
},
367+
});
368+
}).not.toThrow();
369+
});
370+
it('should allow specifying the template to omit visibleFiles', () => {
361371
expect(() => {
362372
webcontainerSchema.parse({
363373
template: {

Diff for: packages/types/src/schemas/common.ts

+3
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ export const webcontainerSchema = commandsSchema.extend({
177177
z.strictObject({
178178
// name of the template
179179
name: z.string(),
180+
181+
// list of globs of files that should be visible
182+
visibleFiles: z.array(z.string()).optional(),
180183
}),
181184
])
182185
.describe(

Diff for: pnpm-lock.yaml

+16
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)