diff --git a/docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx b/docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx
index a2b50e5d..19a84264 100644
--- a/docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx
+++ b/docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx
@@ -3,6 +3,7 @@ title: Content creation
description: 'Creating content in TutorialKit.'
---
import { FileTree } from '@astrojs/starlight/components';
+import { Tabs, TabItem } from '@astrojs/starlight/components';
From an information architecture perspective, tutorial content is divided into **parts**, which are further divided into **chapters**, each consisting of **lessons**.
@@ -110,6 +111,19 @@ template: my-advanced-template
This declaration will make TutorialKit use the `src/templates/my-advanced-template` directory as the base for the lesson.
+By default files in template are not shown in the code editor.
+To make them visible, you can use `visibleFiles` option.
+This can reduce repetition when you want to show same files visible in multiple lessons.
+
+```markdown {5}
+---
+title: Advanced Topics
+template:
+ name: my-advanced-template
+ visibleFiles: ['src/index.js', '**/utils/**']
+---
+```
+
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:
```json
@@ -144,3 +158,75 @@ src/templates
│ # Overrides "index.js" from "shared-template"
└── index.js
```
+
+## Editor File Visibility
+
+Editor's files are resolved in three steps. Each step overrides previous one:
+
+1. Display files matching `template.visibleFiles` (lowest priority)
+2. Display files from `_files` directory
+3. When solution is revealed, display files from `_solution` directory. (highest priority)
+
+
+
+
+```markdown ins=/.{24}├── (first.js)/ ins=/└── (second.js)/ ins=/third.js/
+---
+template:
+ name: default
+ visibleFiles: ['src/**']
+---
+
+src
+├── content
+│ └── tutorial
+│ └── 1-basics
+│ └── 1-introduction
+│ └── 1-welcome
+│ ├── _files
+│ │ ├── first.js
+│ │ └── second.js
+│ └── _solution
+│ └── first.js
+└── templates
+ └── default
+ ├── src
+ │ ├── first.js
+ │ ├── second.js
+ │ └── third.js
+ └── package.json
+```
+
+
+
+
+
+```markdown ins=/└── (first.js)/ ins=/└── (second.js)/ ins=/third.js/
+---
+template:
+ name: default
+ visibleFiles: ['src/**']
+---
+
+src
+├── content
+│ └── tutorial
+│ └── 1-basics
+│ └── 1-introduction
+│ └── 1-welcome
+│ ├── _files
+│ │ ├── first.js
+│ │ └── second.js
+│ └── _solution
+│ └── first.js
+└── templates
+ └── default
+ ├── src
+ │ ├── first.js
+ │ ├── second.js
+ │ └── third.js
+ └── package.json
+```
+
+
+
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 5e374b01..14839f73 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -51,6 +51,7 @@
"kleur": "4.1.5",
"mdast-util-directive": "^3.0.0",
"mdast-util-to-markdown": "^2.1.0",
+ "micromatch": "^4.0.7",
"nanostores": "^0.10.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -63,6 +64,7 @@
"devDependencies": {
"@tutorialkit/types": "workspace:*",
"@types/mdast": "^4.0.4",
+ "@types/micromatch": "^4.0.9",
"esbuild": "^0.20.2",
"esbuild-node-externals": "^1.13.1",
"execa": "^9.2.0",
diff --git a/packages/astro/src/default/utils/content.ts b/packages/astro/src/default/utils/content.ts
index 68d2cb55..2bc1cfbf 100644
--- a/packages/astro/src/default/utils/content.ts
+++ b/packages/astro/src/default/utils/content.ts
@@ -1,6 +1,7 @@
import type { ChapterSchema, Lesson, LessonSchema, PartSchema, Tutorial, TutorialSchema } from '@tutorialkit/types';
import { interpolateString } from '@tutorialkit/types';
import { getCollection } from 'astro:content';
+import micromatch from 'micromatch';
import path from 'node:path';
import { DEFAULT_LOCALIZATION } from './content/default-localization';
import { squash } from './content/squash.js';
@@ -8,6 +9,8 @@ import { logger } from './logger';
import { joinPaths } from './url';
import { getFilesRefList } from './content/files-ref';
+const TEMPLATES_DIR = path.join(process.cwd(), 'src/templates');
+
export async function getTutorial(): Promise {
const collection = sortCollection(await getCollection('tutorial'));
@@ -232,6 +235,7 @@ export async function getTutorial(): Promise {
const partMetadata = _tutorial.parts[lesson.part.id].data;
const chapterMetadata = _tutorial.parts[lesson.part.id].chapters[lesson.chapter.id].data;
+ // now we inherit options from upper levels
lesson.data = {
...lesson.data,
...squash(
@@ -252,6 +256,22 @@ export async function getTutorial(): Promise {
),
};
+ if (lesson.data.template && typeof lesson.data.template !== 'string' && lesson.data.template.visibleFiles?.length) {
+ const [, tempalteFiles] = await getFilesRefList(lesson.data.template.name, TEMPLATES_DIR);
+
+ for (const filename of tempalteFiles) {
+ if (lesson.files[1].includes(filename)) {
+ continue;
+ }
+
+ if (micromatch.isMatch(filename, lesson.data.template.visibleFiles, { basename: true })) {
+ lesson.files[1].push(filename);
+ }
+ }
+
+ lesson.files[1].sort();
+ }
+
if (prevLesson) {
const partSlug = _tutorial.parts[prevLesson.part.id].slug;
const chapterSlug = _tutorial.parts[prevLesson.part.id].chapters[prevLesson.chapter.id].slug;
diff --git a/packages/astro/src/remark/import-file.ts b/packages/astro/src/remark/import-file.ts
index 8447b746..0c177652 100644
--- a/packages/astro/src/remark/import-file.ts
+++ b/packages/astro/src/remark/import-file.ts
@@ -78,7 +78,7 @@ function getTemplateName(file: string) {
);
if (meta.attributes.template) {
- return meta.attributes.template;
+ return typeof meta.attributes.template === 'string' ? meta.attributes.template : meta.attributes.template.name;
}
/**
diff --git a/packages/cli/src/commands/eject/index.ts b/packages/cli/src/commands/eject/index.ts
index d7070718..c41eec55 100644
--- a/packages/cli/src/commands/eject/index.ts
+++ b/packages/cli/src/commands/eject/index.ts
@@ -25,6 +25,8 @@ const REQUIRED_DEPENDENCIES = [
'@nanostores/react',
'kleur',
'@stackblitz/sdk',
+ 'micromatch',
+ '@types/micromatch',
];
export function ejectRoutes(flags: Arguments) {
@@ -111,6 +113,7 @@ async function _eject(flags: EjectOptions) {
for (const dep of REQUIRED_DEPENDENCIES) {
if (!(dep in pkgJson.dependencies) && !(dep in pkgJson.devDependencies)) {
pkgJson.dependencies[dep] = astroIntegrationPkgJson.dependencies[dep];
+ pkgJson.devDependencies[dep] = astroIntegrationPkgJson.devDependencies[dep];
newDependencies.push(dep);
}
diff --git a/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap b/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap
index 38119165..62236b66 100644
--- a/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap
+++ b/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap
@@ -75,6 +75,7 @@ exports[`create a project 1`] = `
"src/templates/default/package.json",
"src/templates/default/src",
"src/templates/default/src/index.js",
+ "src/templates/default/src/template-only-file.js",
"src/templates/vite-app",
"src/templates/vite-app-2",
"src/templates/vite-app-2/.tk-config.json",
@@ -119,6 +120,7 @@ exports[`create and build a project > built project file references 1`] = `
"/package-lock.json",
"/package.json",
"/src/index.js",
+ "/src/template-only-file.js",
],
"template-vite-app-2.json": [
"/.gitignore",
@@ -286,6 +288,7 @@ exports[`create and eject a project 1`] = `
"src/templates/default/package.json",
"src/templates/default/src",
"src/templates/default/src/index.js",
+ "src/templates/default/src/template-only-file.js",
"src/templates/vite-app",
"src/templates/vite-app-2",
"src/templates/vite-app-2/.tk-config.json",
diff --git a/packages/runtime/src/lesson-files.ts b/packages/runtime/src/lesson-files.ts
index 422bcee4..119915db 100644
--- a/packages/runtime/src/lesson-files.ts
+++ b/packages/runtime/src/lesson-files.ts
@@ -42,7 +42,8 @@ export class LessonFilesFetcher {
}
async getLessonTemplate(lesson: Lesson): Promise {
- const templatePathname = `template-${lesson.data.template}.json`;
+ const templateName = typeof lesson.data.template === 'string' ? lesson.data.template : lesson.data.template?.name;
+ const templatePathname = `template-${templateName}.json`;
if (this._map.has(templatePathname)) {
return this._map.get(templatePathname)!;
diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts
index cdb6f357..e1d26c88 100644
--- a/packages/runtime/src/store/index.ts
+++ b/packages/runtime/src/store/index.ts
@@ -42,10 +42,18 @@ export class TutorialStore {
private _ref: number = 1;
private _themeRef = atom(1);
+ /** Files from lesson's `_files` directory */
private _lessonFiles: Files | undefined;
+
+ /** Files from lesson's `_solution` directory */
private _lessonSolution: Files | undefined;
+
+ /** All files from `template` directory */
private _lessonTemplate: Files | undefined;
+ /** Files from `template` directory that match `template.visibleFiles` patterns */
+ private _visibleTemplateFiles: Files | undefined;
+
/**
* Whether or not the current lesson is fully loaded in WebContainer
* and in every stores.
@@ -165,15 +173,17 @@ export class TutorialStore {
signal.throwIfAborted();
- this._lessonTemplate = template;
this._lessonFiles = files;
this._lessonSolution = solution;
+ this._lessonTemplate = template;
+ this._visibleTemplateFiles = pick(template, lesson.files[1]);
- this._editorStore.setDocuments(files);
+ const editorFiles = { ...this._visibleTemplateFiles, ...this._lessonFiles };
+ this._editorStore.setDocuments(editorFiles);
if (lesson.data.focus === undefined) {
this._editorStore.setSelectedFile(undefined);
- } else if (files[lesson.data.focus] !== undefined) {
+ } else if (editorFiles[lesson.data.focus] !== undefined) {
this._editorStore.setSelectedFile(lesson.data.focus);
}
@@ -283,8 +293,10 @@ export class TutorialStore {
return;
}
- this._editorStore.setDocuments(this._lessonFiles);
- this._runner.updateFiles(this._lessonFiles);
+ const files = { ...this._visibleTemplateFiles, ...this._lessonFiles };
+
+ this._editorStore.setDocuments(files);
+ this._runner.updateFiles(files);
}
solve() {
@@ -294,7 +306,7 @@ export class TutorialStore {
return;
}
- const files = { ...this._lessonFiles, ...this._lessonSolution };
+ const files = { ...this._visibleTemplateFiles, ...this._lessonFiles, ...this._lessonSolution };
this._editorStore.setDocuments(files);
this._runner.updateFiles(files);
@@ -361,3 +373,15 @@ export class TutorialStore {
return this._runner.takeSnapshot();
}
}
+
+function pick(obj: Record, entries: string[]) {
+ const result: Record = {};
+
+ for (const entry of entries) {
+ if (entry in obj) {
+ result[entry] = obj[entry];
+ }
+ }
+
+ return result;
+}
diff --git a/packages/template/src/content/tutorial/1-basics/1-introduction/1-welcome/content.md b/packages/template/src/content/tutorial/1-basics/1-introduction/1-welcome/content.md
index 9c35459b..96916a03 100644
--- a/packages/template/src/content/tutorial/1-basics/1-introduction/1-welcome/content.md
+++ b/packages/template/src/content/tutorial/1-basics/1-introduction/1-welcome/content.md
@@ -1,7 +1,7 @@
---
type: lesson
title: Welcome to TutorialKit
-focus: /src/index.js
+focus: /src/template-only-file.js
previews: [8080]
mainCommand: ['node -e setTimeout(()=>{},10_000)', 'Running dev server']
prepareCommands:
@@ -11,6 +11,9 @@ prepareCommands:
- ['node -e setTimeout(()=>{process.exit(1)},5000)', 'This is going to fail']
terminal:
panels: ['terminal', 'output']
+template:
+ name: default
+ visibleFiles: ['template-only-file.js']
---
# Kitchen Sink [Heading 1]
diff --git a/packages/template/src/templates/default/src/template-only-file.js b/packages/template/src/templates/default/src/template-only-file.js
new file mode 100644
index 00000000..f0b3454b
--- /dev/null
+++ b/packages/template/src/templates/default/src/template-only-file.js
@@ -0,0 +1 @@
+export default 'This file is only present in template';
diff --git a/packages/types/src/schemas/common.spec.ts b/packages/types/src/schemas/common.spec.ts
index 1d5c7b37..d16cb1ec 100644
--- a/packages/types/src/schemas/common.spec.ts
+++ b/packages/types/src/schemas/common.spec.ts
@@ -350,13 +350,32 @@ describe('webcontainerSchema', () => {
}).not.toThrow();
});
- it('should allow specifying the template', () => {
+ it('should allow specifying the template by name', () => {
expect(() => {
webcontainerSchema.parse({
template: 'default',
});
}).not.toThrow();
});
+ it('should allow specifying the template by object type', () => {
+ expect(() => {
+ webcontainerSchema.parse({
+ template: {
+ name: 'default',
+ visibleFiles: ['**/fixture.json', '*/tests/*'],
+ },
+ });
+ }).not.toThrow();
+ });
+ it('should allow specifying the template to omit visibleFiles', () => {
+ expect(() => {
+ webcontainerSchema.parse({
+ template: {
+ name: 'default',
+ },
+ });
+ }).not.toThrow();
+ });
it('should allow specifying the terminal', () => {
expect(() => {
webcontainerSchema.parse({
diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts
index 56c56c2c..c25bd325 100644
--- a/packages/types/src/schemas/common.ts
+++ b/packages/types/src/schemas/common.ts
@@ -175,7 +175,18 @@ export const webcontainerSchema = commandsSchema.extend({
'Navigating to a lesson that specifies autoReload will always reload the preview. This is typically only needed if your server does not support HMR.',
),
template: z
- .string()
+ .union([
+ // name of the template
+ z.string(),
+
+ z.strictObject({
+ // name of the template
+ name: z.string(),
+
+ // list of globs of files that should be visible
+ visibleFiles: z.array(z.string()).optional().describe('Specifies which files from template should be visible'),
+ }),
+ ])
.optional()
.describe(
'Specifies which folder from the `src/templates/` directory should be used as the basis for the code. See the "Code templates" guide for a detailed explainer.',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bebf8c76..131e8494 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -299,6 +299,9 @@ importers:
mdast-util-to-markdown:
specifier: ^2.1.0
version: 2.1.0
+ micromatch:
+ specifier: ^4.0.7
+ version: 4.0.7
nanostores:
specifier: ^0.10.3
version: 0.10.3
@@ -327,6 +330,9 @@ importers:
'@types/mdast':
specifier: ^4.0.4
version: 4.0.4
+ '@types/micromatch':
+ specifier: ^4.0.9
+ version: 4.0.9
esbuild:
specifier: ^0.20.2
version: 0.20.2
@@ -3144,6 +3150,10 @@ packages:
dependencies:
'@babel/types': 7.24.5
+ /@types/braces@3.0.4:
+ resolution: {integrity: sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==}
+ dev: true
+
/@types/conventional-commits-parser@5.0.0:
resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==}
dependencies:
@@ -3207,6 +3217,12 @@ packages:
/@types/mdx@2.0.13:
resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
+ /@types/micromatch@4.0.9:
+ resolution: {integrity: sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==}
+ dependencies:
+ '@types/braces': 3.0.4
+ dev: true
+
/@types/ms@0.7.34:
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}