Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add template.visibleFiles option #165

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

<Tabs syncKey="file-visibilty">
<TabItem label="Initially">

```markdown ins=/.{24}├── (first.js)/ ins=/└── (second.js)/ ins=/third.js/
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This patterns might look a bit weird. It's matching the words on specific rows. I don't think expressive-code supports syntax like ins={24, "first.js"} to match first.js on line 24 only.

image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing _files/src here 😱

---
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
```

</TabItem>

<TabItem label="After solution is revealed">

```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
```

</TabItem>
</Tabs>
2 changes: 2 additions & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions packages/astro/src/default/utils/content.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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';
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<Tutorial> {
const collection = sortCollection(await getCollection('tutorial'));

Expand Down Expand Up @@ -232,6 +235,7 @@ export async function getTutorial(): Promise<Tutorial> {
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(
Expand All @@ -252,6 +256,22 @@ export async function getTutorial(): Promise<Tutorial> {
),
};

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;
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/remark/import-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/eject/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const REQUIRED_DEPENDENCIES = [
'@nanostores/react',
'kleur',
'@stackblitz/sdk',
'micromatch',
'@types/micromatch',
];

export function ejectRoutes(flags: Arguments) {
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime/src/lesson-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export class LessonFilesFetcher {
}

async getLessonTemplate(lesson: Lesson): Promise<Files> {
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)!;
Expand Down
36 changes: 30 additions & 6 deletions packages/runtime/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed that the files from the template are not read-only.

Given that they aren't part of the lesson but are here to provide information on the project, I really feel like they should be read only. Otherwise it feels like we're moving too far away from the original goal of having tutorials be this "safe" environment where learners can focus on the learning.

Copy link
Member Author

@AriPerkkio AriPerkkio Aug 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we have options to make any file readonly at the moment. Maybe we need such feature, especially once adding new files via terminal becomes possible?

Currently it's intentional that files are modifiable. This is simply to reduce repetition - you don't have to copy-paste files in every single lesson just to make them visible.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we have options to make any file readonly at the moment. Maybe we need such feature, especially once adding new files via terminal becomes possible?

That is correct, until this PR we didn't have a need for them.

I'm not quite sure what we want to do about files being added from a terminal or more generally added in WebContainer by any mean, but I'm tempted to think that we'll want to design a solution that around a specific example.

Currently it's intentional that files are modifiable. This is simply to reduce repetition - you don't have to copy-paste files in every single lesson just to make them visible.

Then IMO it's the wrong abstraction. I never saw visibleFiles as intended to reduce repetition in _files. If we want that then we should maybe consider allowing .tk-config.json in _files (which I think already works but isn't documented) or have a different approach. This feels a bit hacky and against the original idea that templates are not part of the lesson (as in initial files to modify or the final solution).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise it feels like we're moving too far away from the original goal of having tutorials be this "safe" environment where learners can focus on the learning.

Yep, it sounds like we need to reconsider these changes. Maybe it's best to have only files from _files visible, as it currently is. Having files that aren't modifiable in file tree could make tutorials even more confusing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this option shouldn't even be on template, it should be just file system based one. 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that's a good point actually! It's not really a template property but more like what we should show from the fs regardless of where it's coming from.

Hmm I agree maybe this is something we should discuss further, maybe we could start a thread on twitter about it and ping people that have requested the feature? Or maybe we can have the conversation in a GitHub issue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe something like:

editor: 
  fileTree: 
     include: ["..."]
     templateFiles: ["..."]


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);
}

Expand Down Expand Up @@ -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() {
Expand All @@ -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);
Expand Down Expand Up @@ -361,3 +373,15 @@ export class TutorialStore {
return this._runner.takeSnapshot();
}
}

function pick<T>(obj: Record<string, T>, entries: string[]) {
const result: Record<string, T> = {};

for (const entry of entries) {
if (entry in obj) {
result[entry] = obj[entry];
}
}

return result;
}
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'This file is only present in template';
21 changes: 20 additions & 1 deletion packages/types/src/schemas/common.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
13 changes: 12 additions & 1 deletion packages/types/src/schemas/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
Loading
Loading