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