Skip to content

Commit 57adec8

Browse files
committed
feat: add template.visibleFiles option
1 parent a62e777 commit 57adec8

File tree

11 files changed

+175
-7
lines changed

11 files changed

+175
-7
lines changed

docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx

+86
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ title: Content creation
33
description: 'Creating content in TutorialKit.'
44
---
55
import { FileTree } from '@astrojs/starlight/components';
6+
import { Tabs, TabItem } from '@astrojs/starlight/components';
67

78
From an information architecture perspective, tutorial content is divided into **parts**, which are further divided into **chapters**, each consisting of **lessons**.
89

@@ -110,6 +111,19 @@ template: my-advanced-template
110111

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

114+
By default files in template are not shown in the code editor.
115+
To make them visible, you can use `visibleFiles` option.
116+
This can reduce repetition when you want to show same files visible in multiple lessons.
117+
118+
```markdown {5}
119+
---
120+
title: Advanced Topics
121+
template:
122+
name: my-advanced-template
123+
visibleFiles: ['src/index.js', '**/utils/**']
124+
---
125+
```
126+
113127
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:
114128

115129
```json
@@ -144,3 +158,75 @@ src/templates
144158
│ # Overrides "index.js" from "shared-template"
145159
└── index.js
146160
```
161+
162+
## Editor File Visibility
163+
164+
Editor's files are resolved in three steps. Each step overrides previous one:
165+
166+
1. Display files matching `template.visibleFiles` (lowest priority)
167+
2. Display files from `_files` directory
168+
3. When solution is revealed, display files from `_solution` directory. (highest priority)
169+
170+
<Tabs syncKey="file-visibilty">
171+
<TabItem label="Initially">
172+
173+
```markdown ins=/.{24}├── (first.js)/ ins=/└── (second.js)/ ins=/third.js/
174+
---
175+
template:
176+
name: default
177+
visibleFiles: ['src/**']
178+
---
179+
180+
src
181+
├── content
182+
│ └── tutorial
183+
│ └── 1-basics
184+
│ └── 1-introduction
185+
│ └── 1-welcome
186+
│ ├── _files
187+
│ │ ├── first.js
188+
│ │ └── second.js
189+
│ └── _solution
190+
│ └── first.js
191+
└── templates
192+
└── default
193+
├── src
194+
│ ├── first.js
195+
│ ├── second.js
196+
│ └── third.js
197+
└── package.json
198+
```
199+
200+
</TabItem>
201+
202+
<TabItem label="After solution is revealed">
203+
204+
```markdown ins=/└── (first.js)/ ins=/└── (second.js)/ ins=/third.js/
205+
---
206+
template:
207+
name: default
208+
visibleFiles: ['src/**']
209+
---
210+
211+
src
212+
├── content
213+
│ └── tutorial
214+
│ └── 1-basics
215+
│ └── 1-introduction
216+
│ └── 1-welcome
217+
│ ├── _files
218+
│ │ ├── first.js
219+
│ │ └── second.js
220+
│ └── _solution
221+
│ └── first.js
222+
└── templates
223+
└── default
224+
├── src
225+
│ ├── first.js
226+
│ ├── second.js
227+
│ └── third.js
228+
└── package.json
229+
```
230+
231+
</TabItem>
232+
</Tabs>

packages/astro/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"kleur": "4.1.5",
5252
"mdast-util-directive": "^3.0.0",
5353
"mdast-util-to-markdown": "^2.1.0",
54+
"micromatch": "^4.0.7",
5455
"nanostores": "^0.10.3",
5556
"react": "^18.3.1",
5657
"react-dom": "^18.3.1",
@@ -63,6 +64,7 @@
6364
"devDependencies": {
6465
"@tutorialkit/types": "workspace:*",
6566
"@types/mdast": "^4.0.4",
67+
"@types/micromatch": "^4.0.9",
6668
"esbuild": "^0.20.2",
6769
"esbuild-node-externals": "^1.13.1",
6870
"execa": "^9.2.0",

packages/astro/src/default/utils/content.ts

+28
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import type { ChapterSchema, Lesson, LessonSchema, PartSchema, Tutorial, TutorialSchema } from '@tutorialkit/types';
22
import { interpolateString } from '@tutorialkit/types';
33
import { getCollection } from 'astro:content';
4+
import mm from 'micromatch';
45
import path from 'node:path';
56
import { DEFAULT_LOCALIZATION } from './content/default-localization';
67
import { squash } from './content/squash.js';
78
import { logger } from './logger';
89
import { joinPaths } from './url';
910
import { getFilesRefList } from './content/files-ref';
1011

12+
const TEMPLATES_DIR = path.join(process.cwd(), 'src/templates');
13+
1114
export async function getTutorial(): Promise<Tutorial> {
1215
const collection = sortCollection(await getCollection('tutorial'));
1316

@@ -253,6 +256,22 @@ export async function getTutorial(): Promise<Tutorial> {
253256
),
254257
};
255258

259+
if (lesson.data.template && typeof lesson.data.template !== 'string' && lesson.data.template.visibleFiles?.length) {
260+
const templateFilesRef = await getFilesRefList(lesson.data.template.name, TEMPLATES_DIR);
261+
262+
for (const filename of templateFilesRef[1]) {
263+
if (lesson.files[1].includes(filename)) {
264+
continue;
265+
}
266+
267+
if (mm.isMatch(filename, lesson.data.template.visibleFiles, { format: formatTemplateFile })) {
268+
lesson.files[1].push(filename);
269+
}
270+
}
271+
272+
lesson.files[1].sort();
273+
}
274+
256275
if (prevLesson) {
257276
const partSlug = _tutorial.parts[prevLesson.part.id].slug;
258277
const chapterSlug = _tutorial.parts[prevLesson.part.id].chapters[prevLesson.chapter.id].slug;
@@ -321,6 +340,15 @@ function getSlug(entry: CollectionEntryTutorial) {
321340
return slug;
322341
}
323342

343+
function formatTemplateFile(filename: string) {
344+
// compare files without leading "/" so that patterns like ["src/index.js"] match "/src/index.js"
345+
if (filename.startsWith('/')) {
346+
return filename.substring(1);
347+
}
348+
349+
return filename;
350+
}
351+
324352
interface CollectionEntryTutorial {
325353
id: string;
326354
slug: string;

packages/cli/src/commands/eject/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const REQUIRED_DEPENDENCIES = [
2525
'@nanostores/react',
2626
'kleur',
2727
'@stackblitz/sdk',
28+
'micromatch',
29+
'@types/micromatch',
2830
];
2931

3032
export function ejectRoutes(flags: Arguments) {
@@ -111,6 +113,7 @@ async function _eject(flags: EjectOptions) {
111113
for (const dep of REQUIRED_DEPENDENCIES) {
112114
if (!(dep in pkgJson.dependencies) && !(dep in pkgJson.devDependencies)) {
113115
pkgJson.dependencies[dep] = astroIntegrationPkgJson.dependencies[dep];
116+
pkgJson.devDependencies[dep] = astroIntegrationPkgJson.devDependencies[dep];
114117

115118
newDependencies.push(dep);
116119
}

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",
@@ -286,6 +287,7 @@ exports[`create and eject a project 1`] = `
286287
"src/templates/default/package.json",
287288
"src/templates/default/src",
288289
"src/templates/default/src/index.js",
290+
"src/templates/default/src/template-only-file.js",
289291
"src/templates/vite-app",
290292
"src/templates/vite-app-2",
291293
"src/templates/vite-app-2/.tk-config.json",

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

@@ -283,8 +293,10 @@ export class TutorialStore {
283293
return;
284294
}
285295

286-
this._editorStore.setDocuments(this._lessonFiles);
287-
this._runner.updateFiles(this._lessonFiles);
296+
const files = { ...this._visibleTemplateFiles, ...this._lessonFiles };
297+
298+
this._editorStore.setDocuments(files);
299+
this._runner.updateFiles(files);
288300
}
289301

290302
solve() {
@@ -294,7 +306,7 @@ export class TutorialStore {
294306
return;
295307
}
296308

297-
const files = { ...this._lessonFiles, ...this._lessonSolution };
309+
const files = { ...this._visibleTemplateFiles, ...this._lessonFiles, ...this._lessonSolution };
298310

299311
this._editorStore.setDocuments(files);
300312
this._runner.updateFiles(files);
@@ -361,3 +373,7 @@ export class TutorialStore {
361373
return this._runner.takeSnapshot();
362374
}
363375
}
376+
377+
function filterEntries<T extends object>(obj: T, filter: string[]) {
378+
return Object.fromEntries(Object.entries(obj).filter(([entry]) => filter.includes(entry)));
379+
}

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';

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: {

packages/types/src/schemas/common.ts

+3
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ export const webcontainerSchema = commandsSchema.extend({
182182
z.strictObject({
183183
// name of the template
184184
name: z.string(),
185+
186+
// list of globs of files that should be visible
187+
visibleFiles: z.array(z.string()).optional(),
185188
}),
186189
])
187190
.describe(

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)