Skip to content

Commit 4b0695d

Browse files
committed
feat: add template.visibleFiles option
1 parent ca4d723 commit 4b0695d

File tree

11 files changed

+183
-10
lines changed

11 files changed

+183
-10
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
@@ -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",

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;

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
}

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",

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+
}

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
@@ -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(

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)