From af0573b589bbe3b36fdb11389c5a802c9f656595 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 9 May 2025 15:18:58 +0200 Subject: [PATCH 01/48] Tutorial app from Markdown WIP Signed-off-by: Mihovil Ilakovac --- web/docs/tutorial/03-pages.md | 45 +- web/docs/tutorial/04-entities.md | 24 + web/docs/tutorial/05-queries.md | 132 +- web/docs/tutorial/06-actions.md | 241 ++- web/docs/tutorial/07-auth.md | 337 +++- web/src/components/TutorialAction.tsx | 13 + web/src/remark/search-and-replace.ts | 4 +- web/tutorial/.gitignore | 3 + web/tutorial/package-lock.json | 1798 ++++++++++++++++++ web/tutorial/package.json | 31 + web/tutorial/src/actions.ts | 41 + web/tutorial/src/generate-patch.ts | 30 + web/tutorial/src/index.ts | 83 + web/tutorial/src/log.ts | 10 + web/tutorial/src/markdown/customHeadingId.ts | 18 + web/tutorial/src/markdown/extractSteps.ts | 118 ++ web/tutorial/src/paths.ts | 2 + web/tutorial/src/waspCli.ts | 14 + web/tutorial/tsconfig.json | 22 + web/tutorial/tutorial.md | 473 +++++ 20 files changed, 3323 insertions(+), 116 deletions(-) create mode 100644 web/src/components/TutorialAction.tsx create mode 100644 web/tutorial/.gitignore create mode 100644 web/tutorial/package-lock.json create mode 100644 web/tutorial/package.json create mode 100644 web/tutorial/src/actions.ts create mode 100644 web/tutorial/src/generate-patch.ts create mode 100644 web/tutorial/src/index.ts create mode 100644 web/tutorial/src/log.ts create mode 100644 web/tutorial/src/markdown/customHeadingId.ts create mode 100644 web/tutorial/src/markdown/extractSteps.ts create mode 100644 web/tutorial/src/paths.ts create mode 100644 web/tutorial/src/waspCli.ts create mode 100644 web/tutorial/tsconfig.json create mode 100644 web/tutorial/tutorial.md diff --git a/web/docs/tutorial/03-pages.md b/web/docs/tutorial/03-pages.md index 50fcd93954..db7010c67a 100644 --- a/web/docs/tutorial/03-pages.md +++ b/web/docs/tutorial/03-pages.md @@ -6,6 +6,7 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs } from '@site/src/components/TsJsHelpers'; import WaspStartNote from '../\_WaspStartNote.md' import TypescriptServerNote from '../\_TypescriptServerNote.md' +import { TutorialAction } from '@site/src/components/TutorialAction'; In the default `main.wasp` file created by `wasp new`, there is a **page** and a **route** declaration: @@ -151,11 +152,15 @@ First, remove most of the code from the `MainPage` component: - ```tsx title="src/MainPage.tsx" - export const MainPage = () => { - return
Hello world!
- } - ``` + + + ```tsx title="src/MainPage.tsx" + export const MainPage = () => { + return
Hello world!
+ } + ``` + +
@@ -187,19 +192,23 @@ Your Wasp file should now look like this: - ```wasp title="main.wasp" - app TodoApp { - wasp: { - version: "{latestWaspVersion}" - }, - title: "TodoApp" - } - - route RootRoute { path: "/", to: MainPage } - page MainPage { - component: import { MainPage } from "@src/MainPage" - } - ``` + + + ```wasp title="main.wasp" + app TodoApp { + wasp: { + version: "{latestWaspVersion}" + }, + title: "TodoApp" + } + + route RootRoute { path: "/", to: MainPage } + page MainPage { + component: import { MainPage } from "@src/MainPage" + } + ``` + + diff --git a/web/docs/tutorial/04-entities.md b/web/docs/tutorial/04-entities.md index 19354fd7f7..d6e24ab53c 100644 --- a/web/docs/tutorial/04-entities.md +++ b/web/docs/tutorial/04-entities.md @@ -3,6 +3,7 @@ title: 4. Database Entities --- import useBaseUrl from '@docusaurus/useBaseUrl'; +import { TutorialAction } from '@site/src/components/TutorialAction'; Entities are one of the most important concepts in Wasp and are how you define what gets stored in the database. @@ -10,6 +11,28 @@ Wasp uses Prisma to talk to the database, and you define Entities by defining Pr Since our Todo app is all about tasks, we'll define a Task entity by adding a Task model in the `schema.prisma` file: + + +```diff +diff --git a/schema.prisma b/schema.prisma +index 190e2a8..65d7d30 100644 +--- a/schema.prisma ++++ b/schema.prisma +@@ -8,3 +8,9 @@ datasource db { + generator client { + provider = "prisma-client-js" + } ++ ++model Task { ++ id Int @id @default(autoincrement()) ++ description String ++ isDone Boolean @default(false) ++} + +``` + + + ```prisma title="schema.prisma" // ... @@ -26,6 +49,7 @@ Read more about how Wasp Entities work in the [Entities](../data-model/entities. To update the database schema to include this entity, stop the `wasp start` process, if it's running, and run: + ```sh wasp db migrate-dev ``` diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index d7f1e5ff7e..9627dd9f21 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -4,6 +4,7 @@ title: 5. Querying the Database import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers'; +import { TutorialAction } from '@site/src/components/TutorialAction'; We want to know which tasks we need to do, so let's list them! @@ -42,6 +43,35 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis + + + ```diff + diff --git a/main.wasp b/main.wasp + index 3a25ea9..ea22c79 100644 + --- a/main.wasp + +++ b/main.wasp + @@ -8,4 +8,15 @@ app TodoApp { + route RootRoute { path: "/", to: MainPage } + page MainPage { + component: import { MainPage } from "@src/MainPage" + -} + \ No newline at end of file + +} + + + +query getTasks { + + // Specifies where the implementation for the query function is. + + // The path `@src/queries` resolves to `src/queries.ts`. + + // No need to specify an extension. + + fn: import { getTasks } from "@src/queries", + + // Tell Wasp that this query reads from the `Task` entity. Wasp will + + // automatically update the results of this query when tasks are modified. + + entities: [Task] + +} + + + + ``` + + ```wasp title="main.wasp" // ... @@ -84,16 +114,20 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis - ```js title="src/queries.ts" - import { Task } from 'wasp/entities' - import { type GetTasks } from 'wasp/server/operations' + - export const getTasks: GetTasks = async (args, context) => { - return context.entities.Task.findMany({ - orderBy: { id: 'asc' }, - }) - } - ``` + ```js title="src/queries.ts" + import { Task } from 'wasp/entities' + import { type GetTasks } from 'wasp/server/operations' + + export const getTasks: GetTasks = async (args, context) => { + return context.entities.Task.findMany({ + orderBy: { id: 'asc' }, + }) + } + ``` + + Wasp automatically generates the types `GetTasks` and `Task` based on the contents of `main.wasp`: @@ -170,44 +204,48 @@ This makes it easy for us to use the `getTasks` Query we just created in our Rea - ```tsx {1-2,5-14,17-36} title="src/MainPage.tsx" - import { Task } from 'wasp/entities' - import { getTasks, useQuery } from 'wasp/client/operations' - - export const MainPage = () => { - const { data: tasks, isLoading, error } = useQuery(getTasks) - - return ( -
- {tasks && } - - {isLoading && 'Loading...'} - {error && 'Error: ' + error} -
- ) - } - - const TaskView = ({ task }: { task: Task }) => { - return ( -
- - {task.description} -
- ) - } - - const TasksList = ({ tasks }: { tasks: Task[] }) => { - if (!tasks?.length) return
No tasks
- - return ( -
- {tasks.map((task, idx) => ( - - ))} -
- ) - } - ``` + + + ```tsx {1-2,5-14,17-36} title="src/MainPage.tsx" + import { Task } from 'wasp/entities' + import { getTasks, useQuery } from 'wasp/client/operations' + + export const MainPage = () => { + const { data: tasks, isLoading, error } = useQuery(getTasks) + + return ( +
+ {tasks && } + + {isLoading && 'Loading...'} + {error && 'Error: ' + error} +
+ ) + } + + const TaskView = ({ task }: { task: Task }) => { + return ( +
+ + {task.description} +
+ ) + } + + const TasksList = ({ tasks }: { tasks: Task[] }) => { + if (!tasks?.length) return
No tasks
+ + return ( +
+ {tasks.map((task, idx) => ( + + ))} +
+ ) + } + ``` + +
diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index b36bd4caf2..ef6db4a48b 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -5,6 +5,7 @@ title: 6. Modifying Data import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers'; import Collapse from '@site/src/components/Collapse'; +import { TutorialAction } from '@site/src/components/TutorialAction'; In the previous section, you learned about using Queries to fetch data. Let's now learn about Actions so you can add and update tasks in the database. @@ -35,6 +36,25 @@ We must first declare the Action in `main.wasp`: + + + ```diff + diff --git a/main.wasp b/main.wasp + index ea22c79..42f8f6e 100644 + --- a/main.wasp + +++ b/main.wasp + @@ -20,3 +20,7 @@ query getTasks { + entities: [Task] + } + + +action createTask { + + fn: import { createTask } from "@src/actions", + + entities: [Task] + +} + + ``` + + ```wasp title="main.wasp" // ... @@ -62,21 +82,25 @@ Let's now define a JavaScriptTypeScript - ```ts title="src/actions.ts" - import { Task } from 'wasp/entities' - import { CreateTask } from 'wasp/server/operations' - - type CreateTaskPayload = Pick - - export const createTask: CreateTask = async ( - args, - context - ) => { - return context.entities.Task.create({ - data: { description: args.description }, - }) - } - ``` + + + ```ts title="src/actions.ts" + import { Task } from 'wasp/entities' + import { CreateTask } from 'wasp/server/operations' + + type CreateTaskPayload = Pick + + export const createTask: CreateTask = async ( + args, + context + ) => { + return context.entities.Task.create({ + data: { description: args.description }, + }) + } + ``` + + Once again, we've annotated the Action with the `CreateTask` and `Task` types generated by Wasp. Just like with queries, defining the types on the implementation makes them available on the frontend, giving us **full-stack type safety**. @@ -97,7 +121,7 @@ Start by defining a form for creating new tasks. // highlight-next-line createTask, getTasks, - useQuery + useQuery, } from 'wasp/client/operations' // ... MainPage, TaskView, TaskList ... @@ -128,6 +152,57 @@ Start by defining a form for creating new tasks. + + + ```diff + diff --git a/src/MainPage.tsx b/src/MainPage.tsx + index 2a12348..a32d3ba 100644 + --- a/src/MainPage.tsx + +++ b/src/MainPage.tsx + @@ -1,5 +1,10 @@ + +import { FormEvent } from 'react' + import { Task } from 'wasp/entities' + -import { getTasks, useQuery } from 'wasp/client/operations' + +import { + + createTask, + + getTasks, + + useQuery, + +} from 'wasp/client/operations' + + export const MainPage = () => { + const { data: tasks, isLoading, error } = useQuery(getTasks) + @@ -33,4 +38,25 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { + ))} + + ) + -} + \ No newline at end of file + +} + + + +const NewTaskForm = () => { + + const handleSubmit = async (event: FormEvent) => { + + event.preventDefault() + + try { + + const target = event.target as HTMLFormElement + + const description = target.description.value + + target.reset() + + await createTask({ description }) + + } catch (err: any) { + + window.alert('Error: ' + err.message) + + } + + } + + + + return ( + +
+ + + + + +
+ + ) + +} + + ``` + +
```tsx title="src/MainPage.tsx" // highlight-next-line import { FormEvent } from 'react' @@ -136,7 +211,7 @@ Start by defining a form for creating new tasks. // highlight-next-line createTask, getTasks, - useQuery + useQuery, } from 'wasp/client/operations' // ... MainPage, TaskView, TaskList ... @@ -181,7 +256,7 @@ All that's left now is adding this form to the page component: import { createTask, getTasks, - useQuery + useQuery, } from 'wasp/client/operations' const MainPage = () => { @@ -205,13 +280,33 @@ All that's left now is adding this form to the page component:
+ + + ```diff + diff --git a/src/MainPage.tsx b/src/MainPage.tsx + index a32d3ba..c075f7d 100644 + --- a/src/MainPage.tsx + +++ b/src/MainPage.tsx + @@ -11,6 +11,8 @@ export const MainPage = () => { + + return ( +
+ + + + + {tasks && } + + {isLoading && 'Loading...'} + + ``` + + ```tsx title="src/MainPage.tsx" import { FormEvent } from 'react' import { Task } from 'wasp/entities' import { createTask, getTasks, - useQuery + useQuery, } from 'wasp/client/operations' const MainPage = () => { @@ -281,13 +376,33 @@ Since we've already created one task together, try to create this one yourself. + + + ```diff + diff --git a/main.wasp b/main.wasp + index 42f8f6e..bffede4 100644 + --- a/main.wasp + +++ b/main.wasp + @@ -24,3 +24,8 @@ action createTask { + fn: import { createTask } from "@src/actions", + entities: [Task] + } + + + +action updateTask { + + fn: import { updateTask } from "@src/actions", + + entities: [Task] + +} + + ``` + + ```wasp title="main.wasp" // ... action updateTask { fn: import { updateTask } from "@src/actions", entities: [Task] - } + } ``` @@ -311,6 +426,45 @@ Since we've already created one task together, try to create this one yourself. + + + ```diff + diff --git a/src/actions.ts b/src/actions.ts + index 3edb8fb..45c82eb 100644 + --- a/src/actions.ts + +++ b/src/actions.ts + @@ -1,5 +1,5 @@ + import { Task } from 'wasp/entities' + -import { CreateTask } from 'wasp/server/operations' + +import { CreateTask, UpdateTask } from 'wasp/server/operations' + + type CreateTaskPayload = Pick + + @@ -10,4 +10,18 @@ export const createTask: CreateTask = async ( + return context.entities.Task.create({ + data: { description: args.description }, + }) + -} + \ No newline at end of file + +} + + + +type UpdateTaskPayload = Pick + + + +export const updateTask: UpdateTask = async ( + + { id, isDone }, + + context + +) => { + + return context.entities.Task.update({ + + where: { id }, + + data: { + + isDone: isDone, + + }, + + }) + +} + + ``` + + ```ts title="src/actions.ts" import { CreateTask, UpdateTask } from 'wasp/server/operations' @@ -382,6 +536,53 @@ You can now call `updateTask` from the React component: + + + ```diff + diff --git a/src/MainPage.tsx b/src/MainPage.tsx + index a6997e0..fc436c9 100644 + --- a/src/MainPage.tsx + +++ b/src/MainPage.tsx + @@ -1,6 +1,7 @@ + -import { FormEvent } from 'react' + +import { FormEvent, ChangeEvent } from 'react' + import { Task } from 'wasp/entities' + import { + + updateTask, + createTask, + getTasks, + useQuery, + @@ -22,9 +23,25 @@ export const MainPage = () => { + } + + const TaskView = ({ task }: { task: Task }) => { + + const handleIsDoneChange = async (event: ChangeEvent) => { + + try { + + await updateTask({ + + id: task.id, + + isDone: event.target.checked, + + }) + + } catch (error: any) { + + window.alert('Error while updating task: ' + error.message) + + } + + } + + + return ( +
+ - + + + {task.description} +
+ ) + + ``` + +
```tsx title="src/MainPage.tsx" // highlight-next-line import { FormEvent, ChangeEvent } from 'react' diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 5f2eabe4b2..bcae632f38 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -4,6 +4,7 @@ title: 7. Adding Authentication import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers'; +import { TutorialAction } from '@site/src/components/TutorialAction'; Most modern apps need a way to create and authenticate users. Wasp makes this as easy as possible with its first-class auth support. @@ -23,6 +24,29 @@ Since Wasp manages authentication, it will create [the auth related entities](.. You must only add the `User` Entity to keep track of who owns which tasks: + + +```diff +diff --git a/schema.prisma b/schema.prisma +index 65d7d30..e07f47c 100644 +--- a/schema.prisma ++++ b/schema.prisma +@@ -9,6 +9,10 @@ generator client { + provider = "prisma-client-js" + } + ++model User { ++ id Int @id @default(autoincrement()) ++} ++ + model Task { + id Int @id @default(autoincrement()) + description String + +``` + + + ```prisma title="schema.prisma" // ... @@ -35,6 +59,37 @@ model User { Next, tell Wasp to use full-stack [authentication](../auth/overview): + + +```diff +diff --git a/main.wasp b/main.wasp +index 42f8f6e..9b61df0 100644 +--- a/main.wasp ++++ b/main.wasp +@@ -2,7 +2,17 @@ app TodoApp { + wasp: { + version: "^0.16.3" + }, +- title: "TodoApp" ++ title: "TodoApp", ++ auth: { ++ // Tells Wasp which entity to use for storing users. ++ userEntity: User, ++ methods: { ++ // Enable username and password auth. ++ usernameAndPassword: {} ++ }, ++ // We'll see how this is used in a bit. ++ onAuthFailedRedirectTo: "/login" ++ } + } + + route RootRoute { path: "/", to: MainPage } + +``` + + + ```wasp title="main.wasp" app TodoApp { wasp: { @@ -60,6 +115,8 @@ app TodoApp { Don't forget to update the database schema by running: + + ```sh wasp db migrate-dev ``` @@ -97,6 +154,33 @@ Wasp creates the login and signup forms for us, but we still need to define the
+ + + ```diff + diff --git a/main.wasp b/main.wasp + index 4dccb7d..12a0895 100644 + --- a/main.wasp + +++ b/main.wasp + @@ -39,3 +39,13 @@ action updateTask { + fn: import { updateTask } from "@src/actions", + entities: [Task] + } + + + +route SignupRoute { path: "/signup", to: SignupPage } + +page SignupPage { + + component: import { SignupPage } from "@src/SignupPage" + +} + + + +route LoginRoute { path: "/login", to: LoginPage } + +page LoginPage { + + component: import { LoginPage } from "@src/LoginPage" + +} + + ``` + + + + ```wasp title="main.wasp" // ... @@ -138,22 +222,26 @@ Here's the React code for the pages you've just imported: - ```tsx title="src/LoginPage.tsx" - import { Link } from 'react-router-dom' - import { LoginForm } from 'wasp/client/auth' + + + ```tsx title="src/LoginPage.tsx" + import { Link } from 'react-router-dom' + import { LoginForm } from 'wasp/client/auth' + + export const LoginPage = () => { + return ( +
+ +
+ + I don't have an account yet (go to signup). + +
+ ) + } + ``` - export const LoginPage = () => { - return ( -
- -
- - I don't have an account yet (go to signup). - -
- ) - } - ``` +
@@ -180,22 +268,26 @@ The signup page is very similar to the login page: - ```tsx title="src/SignupPage.tsx" - import { Link } from 'react-router-dom' - import { SignupForm } from 'wasp/client/auth' + + + ```tsx title="src/SignupPage.tsx" + import { Link } from 'react-router-dom' + import { SignupForm } from 'wasp/client/auth' + + export const SignupPage = () => { + return ( +
+ +
+ + I already have an account (go to login). + +
+ ) + } + ``` - export const SignupPage = () => { - return ( -
- -
- - I already have an account (go to login). - -
- ) - } - ``` +
@@ -209,6 +301,25 @@ The signup page is very similar to the login page: We don't want users who are not logged in to access the main page, because they won't be able to create any tasks. So let's make the page private by requiring the user to be logged in: + + +```diff +diff --git a/main.wasp b/main.wasp +index 12a0895..c621b88 100644 +--- a/main.wasp ++++ b/main.wasp +@@ -17,6 +17,7 @@ app TodoApp { + + route RootRoute { path: "/", to: MainPage } + page MainPage { ++ authRequired: true, + component: import { MainPage } from "@src/MainPage" + } + + +``` + + ```wasp title="main.wasp" // ... @@ -235,6 +346,29 @@ Additionally, when `authRequired` is `true`, the page's React component will be + + + ```diff + diff --git a/src/MainPage.tsx b/src/MainPage.tsx + index 0f58592..bb64039 100644 + --- a/src/MainPage.tsx + +++ b/src/MainPage.tsx + @@ -6,8 +6,9 @@ import { + getTasks, + useQuery, + } from 'wasp/client/operations' + +import { AuthUser } from 'wasp/auth' + + -export const MainPage = () => { + +export const MainPage = ({ user }: { user: AuthUser }) => { + const { data: tasks, isLoading, error } = useQuery(getTasks) + + return ( + + ``` + + + ```tsx title="src/MainPage.tsx" import { AuthUser } from 'wasp/auth' @@ -271,6 +405,34 @@ However, you will notice that if you try logging in as different users and creat First, let's define a one-to-many relation between users and tasks (check the [Prisma docs on relations](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/relations)): + + +```diff +diff --git a/schema.prisma b/schema.prisma +index e07f47c..aad0a3c 100644 +--- a/schema.prisma ++++ b/schema.prisma +@@ -10,11 +10,14 @@ generator client { + } + + model User { +- id Int @id @default(autoincrement()) ++ id Int @id @default(autoincrement()) ++ tasks Task[] + } + + model Task { + id Int @id @default(autoincrement()) + description String + isDone Boolean @default(false) ++ user User? @relation(fields: [userId], references: [id]) ++ userId Int? + } + +``` + + + ```prisma title="schema.prisma" // ... @@ -293,6 +455,7 @@ model Task { As always, you must migrate the database after changing the Entities: + ```sh wasp db migrate-dev ``` @@ -331,6 +494,35 @@ Next, let's update the queries and actions to forbid access to non-authenticated + + + ```diff + diff --git a/src/queries.ts b/src/queries.ts + index 49d17ec..dc744eb 100644 + --- a/src/queries.ts + +++ b/src/queries.ts + @@ -1,8 +1,13 @@ + import { Task } from 'wasp/entities' + -import { type GetTasks } from 'wasp/server/operations' + +import { HttpError } from 'wasp/server' + +import { GetTasks } from 'wasp/server/operations' + + export const getTasks: GetTasks = async (args, context) => { + + if (!context.user) { + + throw new HttpError(401) + + } + return context.entities.Task.findMany({ + + where: { user: { id: context.user.id } }, + orderBy: { id: 'asc' }, + }) + -} + \ No newline at end of file + +} + + ``` + + + ```ts title="src/queries.ts" import { Task } from 'wasp/entities' // highlight-next-line @@ -383,6 +575,64 @@ Next, let's update the queries and actions to forbid access to non-authenticated + + + + ```diff + diff --git a/src/actions.ts b/src/actions.ts + index 45c82eb..1a0d088 100644 + --- a/src/actions.ts + +++ b/src/actions.ts + @@ -1,4 +1,5 @@ + import { Task } from 'wasp/entities' + +import { HttpError } from 'wasp/server' + import { CreateTask, UpdateTask } from 'wasp/server/operations' + + type CreateTaskPayload = Pick + @@ -7,21 +8,28 @@ export const createTask: CreateTask = async ( + args, + context + ) => { + + if (!context.user) { + + throw new HttpError(401) + + } + return context.entities.Task.create({ + - data: { description: args.description }, + + data: { + + description: args.description, + + user: { connect: { id: context.user.id } }, + + }, + }) + } + + type UpdateTaskPayload = Pick + + -export const updateTask: UpdateTask = async ( + - { id, isDone }, + - context + -) => { + - return context.entities.Task.update({ + - where: { id }, + - data: { + - isDone: isDone, + - }, + +export const updateTask: UpdateTask< + + UpdateTaskPayload, + + { count: number } + +> = async ({ id, isDone }, context) => { + + if (!context.user) { + + throw new HttpError(401) + + } + + return context.entities.Task.updateMany({ + + where: { id, user: { id: context.user.id } }, + + data: { isDone }, + }) + } + + ``` + + + ```ts {2,11-13,17,28-30,32} title="src/actions.ts" import { Task } from 'wasp/entities' import { HttpError } from 'wasp/server' @@ -463,6 +713,33 @@ Last, but not least, let's add the logout functionality: + + + ```diff + diff --git a/src/MainPage.tsx b/src/MainPage.tsx + index bb64039..abe5672 100644 + --- a/src/MainPage.tsx + +++ b/src/MainPage.tsx + @@ -7,6 +7,7 @@ import { + useQuery, + } from 'wasp/client/operations' + import { AuthUser } from 'wasp/auth' + +import { logout } from 'wasp/client/auth' + + export const MainPage = ({ user }: { user: AuthUser }) => { + const { data: tasks, isLoading, error } = useQuery(getTasks) + @@ -19,6 +20,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { + + {isLoading && 'Loading...'} + {error && 'Error: ' + error} + + +
+ ) + } + + ``` + +
```tsx {2,10} title="src/MainPage.tsx" // ... import { logout } from 'wasp/client/auth' diff --git a/web/src/components/TutorialAction.tsx b/web/src/components/TutorialAction.tsx new file mode 100644 index 0000000000..e19e5ffaa4 --- /dev/null +++ b/web/src/components/TutorialAction.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +export function TutorialAction({ + children, + action, +}: React.PropsWithChildren<{ + action: 'diff' | 'write' | 'migrate-db' +}>) { + if (action === 'write') { + return children + } + return null +} diff --git a/web/src/remark/search-and-replace.ts b/web/src/remark/search-and-replace.ts index c5b37619a7..4c1821847c 100644 --- a/web/src/remark/search-and-replace.ts +++ b/web/src/remark/search-and-replace.ts @@ -12,7 +12,7 @@ const replacements = [ }, ] -const plugin: Plugin<[], Root> = () => (tree) => { +export function visitor(tree: Root) { visit(tree, (node) => { // NOTE: For now we only replace in code blocks to keep // the search and replace logic simple. @@ -24,4 +24,6 @@ const plugin: Plugin<[], Root> = () => (tree) => { }) } +const plugin: Plugin<[], Root> = () => visitor + export default plugin diff --git a/web/tutorial/.gitignore b/web/tutorial/.gitignore new file mode 100644 index 0000000000..a8aac67f63 --- /dev/null +++ b/web/tutorial/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +TodoApp/ +patches/ \ No newline at end of file diff --git a/web/tutorial/package-lock.json b/web/tutorial/package-lock.json new file mode 100644 index 0000000000..1cd7114b6b --- /dev/null +++ b/web/tutorial/package-lock.json @@ -0,0 +1,1798 @@ +{ + "name": "tutorial", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tutorial", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@commander-js/extra-typings": "^13.1.0", + "acorn": "^8.14.1", + "commander": "^13.1.0", + "dedent": "^1.6.0", + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-frontmatter": "^2.0.1", + "mdast-util-mdx": "^3.0.0", + "mdast-util-mdx-jsx": "^3.2.0", + "mdast-util-to-markdown": "^2.1.2", + "micromark-extension-mdx-jsx": "^3.0.2", + "remark-comment": "^1.0.0", + "unist-util-visit": "^5.0.0", + "zx": "^8.5.3" + }, + "devDependencies": { + "@types/mdast": "^4.0.4", + "tsx": "^4.19.4" + } + }, + "node_modules/@commander-js/extra-typings": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz", + "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==", + "license": "MIT", + "peerDependencies": { + "commander": "~13.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", + "integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dedent": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", + "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-frontmatter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", + "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "escape-string-regexp": "^5.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-frontmatter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", + "license": "MIT", + "dependencies": { + "fault": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/remark-comment": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/remark-comment/-/remark-comment-1.0.0.tgz", + "integrity": "sha512-k8YPo5MGvl8l4gGxOH6Zk4Fa2AhDACN5eqKnKZcHDORZQS15hlnezlBHj2lqyDiqzApNmYOMTibkEJbMSKU25w==", + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.1.0", + "micromark-util-symbol": "^1.0.1" + } + }, + "node_modules/remark-comment/node_modules/micromark-factory-space": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/remark-comment/node_modules/micromark-util-character": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/remark-comment/node_modules/micromark-util-symbol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/remark-comment/node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsx": { + "version": "4.19.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", + "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/zx": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/zx/-/zx-8.5.3.tgz", + "integrity": "sha512-TsGLAt8Ngr4wDXLZmN9BT+6FWVLFbqdQ0qpXkV3tIfH7F+MgN/WUeSY7W4nNqAntjWunmnRaznpyxtJRPhCbUQ==", + "license": "Apache-2.0", + "bin": { + "zx": "build/cli.js" + }, + "engines": { + "node": ">= 12.17.0" + } + } + } +} diff --git a/web/tutorial/package.json b/web/tutorial/package.json new file mode 100644 index 0000000000..efa4a5c599 --- /dev/null +++ b/web/tutorial/package.json @@ -0,0 +1,31 @@ +{ + "name": "tutorial", + "version": "0.0.1", + "main": "index.ts", + "type": "module", + "scripts": { + "start": "tsx ./src/index.ts", + "generate-patch": "tsx ./src/generate-patch.ts", + "parse-markdown": "tsx ./src/markdown/index.ts" + }, + "license": "MIT", + "dependencies": { + "@commander-js/extra-typings": "^13.1.0", + "acorn": "^8.14.1", + "commander": "^13.1.0", + "dedent": "^1.6.0", + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-frontmatter": "^2.0.1", + "mdast-util-mdx": "^3.0.0", + "mdast-util-mdx-jsx": "^3.2.0", + "mdast-util-to-markdown": "^2.1.2", + "micromark-extension-mdx-jsx": "^3.0.2", + "remark-comment": "^1.0.0", + "unist-util-visit": "^5.0.0", + "zx": "^8.5.3" + }, + "devDependencies": { + "@types/mdast": "^4.0.4", + "tsx": "^4.19.4" + } +} diff --git a/web/tutorial/src/actions.ts b/web/tutorial/src/actions.ts new file mode 100644 index 0000000000..53823c0681 --- /dev/null +++ b/web/tutorial/src/actions.ts @@ -0,0 +1,41 @@ +import fs from 'fs/promises' +import path from 'path' + +import { $ } from 'zx' + +import { appDir, patchesDir } from './paths' +import { log } from './log' + +type ActionCommon = { + step: number +} + +export type WriteFileAction = { + kind: 'write' + path: string + content: string +} & ActionCommon + +export type ApplyPatchAction = { + kind: 'diff' + patch: string +} & ActionCommon + +export type MigrateDbAction = { + kind: 'migrate-db' +} & ActionCommon + +export type Action = WriteFileAction | ApplyPatchAction | MigrateDbAction + +export async function writeFileToAppDir(file: WriteFileAction) { + const filePath = path.resolve(appDir, file.path) + await fs.writeFile(filePath, file.content) + log('info', `Wrote to ${file.path}`) +} + +export async function applyPatch(patch: ApplyPatchAction) { + const patchPath = path.resolve(patchesDir, `step-${patch.step}.patch`) + await fs.writeFile(patchPath, patch.patch) + await $`cd ${appDir} && git apply ${patchPath} --verbose` + log('info', `Applied patch for step ${patch.step}`) +} diff --git a/web/tutorial/src/generate-patch.ts b/web/tutorial/src/generate-patch.ts new file mode 100644 index 0000000000..e3afef55ee --- /dev/null +++ b/web/tutorial/src/generate-patch.ts @@ -0,0 +1,30 @@ +import { $ } from 'zx' +import readline from 'readline' + +$.verbose = true + +console.log('Committing current state...') +await $`cd ./TodoApp && git add . && git commit -m "checkpoint"` + +console.log('\n==============================================') +console.log('Now make your changes to the TodoApp project.') +console.log('When finished, return here and press Enter to generate the patch.') +console.log('==============================================\n') + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}) + +await new Promise((resolve) => { + rl.question('Press Enter when ready to generate the patch...', () => { + rl.close() + resolve() + }) +}) + +console.log('\n==============================================') + +await $`cd ./TodoApp && git diff` + +console.log('\n==============================================') diff --git a/web/tutorial/src/index.ts b/web/tutorial/src/index.ts new file mode 100644 index 0000000000..c11b2d68f1 --- /dev/null +++ b/web/tutorial/src/index.ts @@ -0,0 +1,83 @@ +import path from 'path' + +import { $, chalk } from 'zx' + +import { applyPatch, writeFileToAppDir, type Action } from './actions' +import { appDir } from './paths' +import { waspDbMigrate, waspNew } from './waspCli' +import { log } from './log' +import { getActionsFromTutorialFiles } from './markdown/extractSteps' +import { program } from '@commander-js/extra-typings' + +const options = program + .option( + '-s, --until-step ', + 'Run until the given step. If not provided, run all steps.', + (value: string) => { + const step = parseInt(value, 10) + if (isNaN(step) || step < 1) { + throw new Error('Step must be a positive integer.') + } + return step + } + ) + .option( + '-e, --edit', + 'Edit mode, you will be offered to edit the diffs', + false + ) + .parse(process.argv) + .opts() + +$.verbose = true + +const actions: Action[] = await getActionsFromTutorialFiles() + +async function prepareApp() { + await $`rm -rf ${appDir}` + await waspNew(appDir) + await $`rm ${path.join(appDir, 'src/Main.css')}` + await $`rm ${path.join(appDir, 'src/waspLogo.png')}` + await $`rm ${path.join(appDir, 'src/MainPage.jsx')}` + // Git needs to be initialized for patches to work + await $`cd ${appDir} && git init` +} + +await prepareApp() + +for (const action of actions) { + if (options.untilStep && action.step === options.untilStep) { + log('info', `Stopping before step ${action.step}`) + process.exit(0) + } + + const kind = action.kind + log('info', `${chalk.bold(`Step ${action.step}`)}: ${kind}`) + + try { + switch (kind) { + case 'diff': + if (options.edit) { + // Ask the user if they want to change the diff + // If yes, don't apply the diff, let them edit manually and generate a new diff + // Display the diff to the user + } else { + await applyPatch(action) + } + break + case 'write': + await writeFileToAppDir(action) + break + case 'migrate-db': + await waspDbMigrate(`step-${action.step}`) + break + default: + kind satisfies never + } + } catch (err) { + log('error', `Error in step ${action.step}: ${err}`) + process.exit(1) + } +} + +log('info', 'All done!') diff --git a/web/tutorial/src/log.ts b/web/tutorial/src/log.ts new file mode 100644 index 0000000000..a37bf1697d --- /dev/null +++ b/web/tutorial/src/log.ts @@ -0,0 +1,10 @@ +import { chalk } from 'zx' + +const colors = { + info: chalk.blue, + error: chalk.red, +} + +export function log(level: keyof typeof colors, message: string) { + console.log(colors[level](`[${level.toUpperCase()}] ${chalk.reset(message)}`)) +} diff --git a/web/tutorial/src/markdown/customHeadingId.ts b/web/tutorial/src/markdown/customHeadingId.ts new file mode 100644 index 0000000000..d2ddb12484 --- /dev/null +++ b/web/tutorial/src/markdown/customHeadingId.ts @@ -0,0 +1,18 @@ +import type { Options } from 'mdast-util-to-markdown' + +declare module 'mdast' { + interface IdString extends Node { + type: 'idString' + value: string + } + + interface RootContentMap { + idString: IdString + } +} + +export const customHeadingId: Options = { + handlers: { + idString: (node) => `{#${node.value}}`, + }, +} diff --git a/web/tutorial/src/markdown/extractSteps.ts b/web/tutorial/src/markdown/extractSteps.ts new file mode 100644 index 0000000000..dadb13391c --- /dev/null +++ b/web/tutorial/src/markdown/extractSteps.ts @@ -0,0 +1,118 @@ +import fs from 'fs/promises' +import path from 'path' + +import * as acorn from 'acorn' +import { mdxJsx } from 'micromark-extension-mdx-jsx' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { mdxJsxFromMarkdown, type MdxJsxFlowElement } from 'mdast-util-mdx-jsx' +import { visit } from 'unist-util-visit' +import type { Action } from '../actions' +import searchAndReplace from '../../../src/remark/search-and-replace.js' + +const componentName = 'TutorialAction' + +export async function getActionsFromTutorialFiles(): Promise { + const files = await fs + .readdir(path.resolve('../docs/tutorial')) + .then((files) => + files + .filter((file) => file.endsWith('.md')) + .sort((a, b) => { + const aNumber = parseInt(a.split('-')[0]!, 10) + const bNumber = parseInt(b.split('-')[0]!, 10) + return aNumber - bNumber + }) + ) + const actions: Action[] = [] + for (const file of files) { + console.log(`Processing file: ${file}`) + const fileActions = await getActionsFromFile( + path.resolve('../docs/tutorial', file) + ) + actions.push(...fileActions) + } + return actions +} + +async function getActionsFromFile(filePath: string): Promise { + const actions = [] as Action[] + const doc = await fs.readFile(path.resolve(filePath)) + + const ast = fromMarkdown(doc, { + extensions: [mdxJsx({ acorn, addResult: true })], + mdastExtensions: [mdxJsxFromMarkdown()], + }) + + // TODO: figure this out + // @ts-ignore + searchAndReplace.visitor(ast) + + visit(ast, 'mdxJsxFlowElement', (node) => { + if (node.name !== componentName) { + return + } + const step = getStep(node) + const action = getAttributeValue(node, 'action') + + if (!step || !action) { + throw new Error('Step and action attributes are required') + } + + if (action === 'migrate-db') { + actions.push({ + kind: 'migrate-db', + step, + }) + return + } + + if (node.children.length !== 1) { + throw new Error(`${componentName} must have exactly one child`) + } + + const childCode = node.children[0] + if (childCode === undefined || childCode.type !== 'code') { + throw new Error(`${componentName} must have a code child`) + } + + const codeBlockCode = childCode.value + + if (action === 'diff') { + actions.push({ + kind: 'diff', + patch: codeBlockCode, + step, + }) + } else if (action === 'write') { + const path = getAttributeValue(node, 'path') + if (!path) { + throw new Error('Path attribute is required for write action') + } + actions.push({ + kind: 'write', + content: codeBlockCode, + path, + step, + }) + } + }) + + return actions +} + +function getStep(node: MdxJsxFlowElement): number | null { + const step = getAttributeValue(node, 'step') + return step !== null ? parseInt(step, 10) : null +} + +function getAttributeValue( + node: MdxJsxFlowElement, + attributeName: string +): string | null { + const attribute = node.attributes.find( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name === attributeName + ) + return attribute && typeof attribute.value === 'string' + ? attribute.value + : null +} diff --git a/web/tutorial/src/paths.ts b/web/tutorial/src/paths.ts new file mode 100644 index 0000000000..b8b12e52c7 --- /dev/null +++ b/web/tutorial/src/paths.ts @@ -0,0 +1,2 @@ +export const appDir = 'TodoApp' +export const patchesDir = 'patches' diff --git a/web/tutorial/src/waspCli.ts b/web/tutorial/src/waspCli.ts new file mode 100644 index 0000000000..69ba5d413b --- /dev/null +++ b/web/tutorial/src/waspCli.ts @@ -0,0 +1,14 @@ +import { $ } from 'zx' + +import { appDir } from './paths' + +export async function waspDbMigrate(migrationName: string): Promise { + await $({ + // Needs to inhert stdio for `wasp db migrate-dev` to work + stdio: 'inherit', + })`cd ${appDir} && wasp db migrate-dev --name ${migrationName}` +} + +export async function waspNew(appName: string): Promise { + await $`wasp new ${appDir}` +} diff --git a/web/tutorial/tsconfig.json b/web/tutorial/tsconfig.json new file mode 100644 index 0000000000..589c7f2011 --- /dev/null +++ b/web/tutorial/tsconfig.json @@ -0,0 +1,22 @@ +// Based on https://www.totaltypescript.com/tsconfig-cheat-sheet +{ + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "verbatimModuleSyntax": true, + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "module": "preserve", + "noEmit": true, + + "lib": ["es2022"] + } +} diff --git a/web/tutorial/tutorial.md b/web/tutorial/tutorial.md new file mode 100644 index 0000000000..7e67585f1e --- /dev/null +++ b/web/tutorial/tutorial.md @@ -0,0 +1,473 @@ +RUN wasp new TodoApp +RUN cd TodoApp + +SET `src/MainPage.tsx` +```tsx +export const MainPage = () => { + return
Hello world!
+} +``` + +SET `main.wasp` +```wasp +app TodoApp { + wasp: { + version: "^0.16.0" + }, + title: "TodoApp" +} + +route RootRoute { path: "/", to: MainPage } +page MainPage { + component: import { MainPage } from "@src/MainPage" +} +``` + +APPEND `schema.prisma` + +```prisma +model Task { + id Int @id @default(autoincrement()) + description String + isDone Boolean @default(false) +} +``` + +RUN wasp db migrate-dev + +APPEND `main.wasp` + +```wasp +query getTasks { + // Specifies where the implementation for the query function is. + // The path `@src/queries` resolves to `src/queries.ts`. + // No need to specify an extension. + fn: import { getTasks } from "@src/queries", + // Tell Wasp that this query reads from the `Task` entity. Wasp will + // automatically update the results of this query when tasks are modified. + entities: [Task] +} +``` + +SET `src/queries.ts` + +```ts +import { Task } from 'wasp/entities' +import { type GetTasks } from 'wasp/server/operations' + +export const getTasks: GetTasks = async (args, context) => { + return context.entities.Task.findMany({ + orderBy: { id: 'asc' }, + }) +} +``` + +SET `src/MainPage.tsx` + +```tsx +import { Task } from 'wasp/entities' +import { getTasks, useQuery } from 'wasp/client/operations' + +export const MainPage = () => { + const { data: tasks, isLoading, error } = useQuery(getTasks) + + return ( +
+ {tasks && } + + {isLoading && 'Loading...'} + {error && 'Error: ' + error} +
+ ) +} + +const TaskView = ({ task }: { task: Task }) => { + return ( +
+ + {task.description} +
+ ) +} + +const TasksList = ({ tasks }: { tasks: Task[] }) => { + if (!tasks?.length) return
No tasks
+ + return ( +
+ {tasks.map((task, idx) => ( + + ))} +
+ ) +} +``` + +APPEND `main.wasp` + +```wasp +action createTask { + fn: import { createTask } from "@src/actions", + entities: [Task] +} +``` + +SET `src/actions.ts` + +```ts +import { Task } from 'wasp/entities' +import { CreateTask } from 'wasp/server/operations' + +type CreateTaskPayload = Pick + +export const createTask: CreateTask = async ( + args, + context +) => { + return context.entities.Task.create({ + data: { description: args.description }, + }) +} +``` + +APPEND `src/MainPage.tsx` + +```tsx +import { FormEvent } from 'react' +import { Task } from 'wasp/entities' +import { + createTask, + getTasks, + useQuery +} from 'wasp/client/operations' + +// ... MainPage, TaskView, TaskList ... + +const NewTaskForm = () => { + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + try { + const target = event.target as HTMLFormElement + const description = target.description.value + target.reset() + await createTask({ description }) + } catch (err: any) { + window.alert('Error: ' + err.message) + } + } + + return ( +
+ + +
+ ) +} +``` + +APPEND `src/MainPage.tsx` + +```tsx +import { FormEvent } from 'react' +import { Task } from 'wasp/entities' +import { + createTask, + getTasks, + useQuery +} from 'wasp/client/operations' + +const MainPage = () => { + const { data: tasks, isLoading, error } = useQuery(getTasks) + + return ( +
+ + + {tasks && } + + {isLoading && 'Loading...'} + {error && 'Error: ' + error} +
+ ) +} + +// ... TaskList, TaskView, NewTaskForm ... +``` + +APPEND `main.wasp` + +```wasp +action updateTask { + fn: import { updateTask } from "@src/actions", + entities: [Task] +} +``` + +APPEND `src/actions.ts` + +```ts +import { CreateTask, UpdateTask } from 'wasp/server/operations' + +// ... + +type UpdateTaskPayload = Pick + +export const updateTask: UpdateTask = async ( + { id, isDone }, + context +) => { + return context.entities.Task.update({ + where: { id }, + data: { + isDone: isDone, + }, + }) +} +``` + +APPEND `src/MainPage.tsx` + +```tsx +import { FormEvent, ChangeEvent } from 'react' +import { Task } from 'wasp/entities' +import { + updateTask, + createTask, + getTasks, + useQuery, +} from 'wasp/client/operations' + + +// ... MainPage ... + +const TaskView = ({ task }: { task: Task }) => { + const handleIsDoneChange = async (event: ChangeEvent) => { + try { + await updateTask({ + id: task.id, + isDone: event.target.checked, + }) + } catch (error: any) { + window.alert('Error while updating task: ' + error.message) + } + } + + return ( +
+ + {task.description} +
+ ) +} + +// ... TaskList, NewTaskForm ... +``` + +APPEND `schema.prisma` + +```prisma +model User { + id Int @id @default(autoincrement()) +} +``` + +APPEND `main.wasp` + +```wasp +app TodoApp { + wasp: { + version: "^0.16.0" + }, + title: "TodoApp", + auth: { + // Tells Wasp which entity to use for storing users. + userEntity: User, + methods: { + // Enable username and password auth. + usernameAndPassword: {} + }, + // We'll see how this is used in a bit. + onAuthFailedRedirectTo: "/login" + } +} + +// ... +``` + +RUN wasp db migrate-dev + +APPEND `main.wasp` + +```wasp +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { SignupPage } from "@src/SignupPage" +} + +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { LoginPage } from "@src/LoginPage" +} +``` + +SET `src/SignupPage.tsx` + +```tsx +import { Link } from 'react-router-dom' +import { SignupForm } from 'wasp/client/auth' + +export const SignupPage = () => { + return ( +
+ +
+ + I already have an account (go to login). + +
+ ) +} +``` + +SET `src/LoginPage.tsx` + +```tsx +import { Link } from 'react-router-dom' +import { LoginForm } from 'wasp/client/auth' + +export const LoginPage = () => { + return ( +
+ +
+ + I don't have an account yet (go to signup). + +
+ ) +} +``` + +APPEND `main.wasp` + +```wasp +// ... + +page MainPage { + authRequired: true, + component: import { MainPage } from "@src/MainPage" +} +``` + +SET `src/MainPage.tsx` + +```tsx +import { AuthUser } from 'wasp/auth' + +export const MainPage = ({ user }: { user: AuthUser }) => { + // Do something with the user + // ... +} +``` + +APPEND `schema.prisma` + +```prisma +// ... + +model User { + id Int @id @default(autoincrement()) + tasks Task[] +} + +model Task { + id Int @id @default(autoincrement()) + description String + isDone Boolean @default(false) + user User? @relation(fields: [userId], references: [id]) + userId Int? +} +``` + +RUN wasp db migrate-dev + +SET `src/queries.ts` + +```ts +import { Task } from 'wasp/entities' +import { HttpError } from 'wasp/server' +import { GetTasks } from 'wasp/server/operations' + +export const getTasks: GetTasks = async (args, context) => { + if (!context.user) { + throw new HttpError(401) + } + return context.entities.Task.findMany({ + where: { user: { id: context.user.id } }, + orderBy: { id: 'asc' }, + }) +} +``` + +SET `src/actions.ts` + +```ts +import { Task } from 'wasp/entities' +import { HttpError } from 'wasp/server' +import { CreateTask, UpdateTask } from 'wasp/server/operations' + +type CreateTaskPayload = Pick + +export const createTask: CreateTask = async ( + args, + context +) => { + if (!context.user) { + throw new HttpError(401) + } + return context.entities.Task.create({ + data: { + description: args.description, + user: { connect: { id: context.user.id } }, + }, + }) +} + +type UpdateTaskPayload = Pick + +export const updateTask: UpdateTask< + UpdateTaskPayload, + { count: number } +> = async ({ id, isDone }, context) => { + if (!context.user) { + throw new HttpError(401) + } + return context.entities.Task.updateMany({ + where: { id, user: { id: context.user.id } }, + data: { isDone }, + }) +} +``` + +APPEND `src/MainPage.tsx` + +```tsx +// ... +import { logout } from 'wasp/client/auth' +//... + +const MainPage = () => { + // ... + return ( +
+ // ... + +
+ ) +} +``` \ No newline at end of file From f25bca5b70eb010e509aa0fc94db3c8a8a438fd7 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 16 May 2025 18:53:06 +0200 Subject: [PATCH 02/48] Cleanup --- web/docs/tutorial/06-actions.md | 1 + web/tutorial/tutorial.md | 473 -------------------------------- 2 files changed, 1 insertion(+), 473 deletions(-) delete mode 100644 web/tutorial/tutorial.md diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index 11200790ee..1543ae5d58 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -39,6 +39,7 @@ index ea22c79..42f8f6e 100644 + fn: import { createTask } from "@src/actions", + entities: [Task] +} + ```
diff --git a/web/tutorial/tutorial.md b/web/tutorial/tutorial.md deleted file mode 100644 index 7e67585f1e..0000000000 --- a/web/tutorial/tutorial.md +++ /dev/null @@ -1,473 +0,0 @@ -RUN wasp new TodoApp -RUN cd TodoApp - -SET `src/MainPage.tsx` -```tsx -export const MainPage = () => { - return
Hello world!
-} -``` - -SET `main.wasp` -```wasp -app TodoApp { - wasp: { - version: "^0.16.0" - }, - title: "TodoApp" -} - -route RootRoute { path: "/", to: MainPage } -page MainPage { - component: import { MainPage } from "@src/MainPage" -} -``` - -APPEND `schema.prisma` - -```prisma -model Task { - id Int @id @default(autoincrement()) - description String - isDone Boolean @default(false) -} -``` - -RUN wasp db migrate-dev - -APPEND `main.wasp` - -```wasp -query getTasks { - // Specifies where the implementation for the query function is. - // The path `@src/queries` resolves to `src/queries.ts`. - // No need to specify an extension. - fn: import { getTasks } from "@src/queries", - // Tell Wasp that this query reads from the `Task` entity. Wasp will - // automatically update the results of this query when tasks are modified. - entities: [Task] -} -``` - -SET `src/queries.ts` - -```ts -import { Task } from 'wasp/entities' -import { type GetTasks } from 'wasp/server/operations' - -export const getTasks: GetTasks = async (args, context) => { - return context.entities.Task.findMany({ - orderBy: { id: 'asc' }, - }) -} -``` - -SET `src/MainPage.tsx` - -```tsx -import { Task } from 'wasp/entities' -import { getTasks, useQuery } from 'wasp/client/operations' - -export const MainPage = () => { - const { data: tasks, isLoading, error } = useQuery(getTasks) - - return ( -
- {tasks && } - - {isLoading && 'Loading...'} - {error && 'Error: ' + error} -
- ) -} - -const TaskView = ({ task }: { task: Task }) => { - return ( -
- - {task.description} -
- ) -} - -const TasksList = ({ tasks }: { tasks: Task[] }) => { - if (!tasks?.length) return
No tasks
- - return ( -
- {tasks.map((task, idx) => ( - - ))} -
- ) -} -``` - -APPEND `main.wasp` - -```wasp -action createTask { - fn: import { createTask } from "@src/actions", - entities: [Task] -} -``` - -SET `src/actions.ts` - -```ts -import { Task } from 'wasp/entities' -import { CreateTask } from 'wasp/server/operations' - -type CreateTaskPayload = Pick - -export const createTask: CreateTask = async ( - args, - context -) => { - return context.entities.Task.create({ - data: { description: args.description }, - }) -} -``` - -APPEND `src/MainPage.tsx` - -```tsx -import { FormEvent } from 'react' -import { Task } from 'wasp/entities' -import { - createTask, - getTasks, - useQuery -} from 'wasp/client/operations' - -// ... MainPage, TaskView, TaskList ... - -const NewTaskForm = () => { - const handleSubmit = async (event: FormEvent) => { - event.preventDefault() - try { - const target = event.target as HTMLFormElement - const description = target.description.value - target.reset() - await createTask({ description }) - } catch (err: any) { - window.alert('Error: ' + err.message) - } - } - - return ( -
- - -
- ) -} -``` - -APPEND `src/MainPage.tsx` - -```tsx -import { FormEvent } from 'react' -import { Task } from 'wasp/entities' -import { - createTask, - getTasks, - useQuery -} from 'wasp/client/operations' - -const MainPage = () => { - const { data: tasks, isLoading, error } = useQuery(getTasks) - - return ( -
- - - {tasks && } - - {isLoading && 'Loading...'} - {error && 'Error: ' + error} -
- ) -} - -// ... TaskList, TaskView, NewTaskForm ... -``` - -APPEND `main.wasp` - -```wasp -action updateTask { - fn: import { updateTask } from "@src/actions", - entities: [Task] -} -``` - -APPEND `src/actions.ts` - -```ts -import { CreateTask, UpdateTask } from 'wasp/server/operations' - -// ... - -type UpdateTaskPayload = Pick - -export const updateTask: UpdateTask = async ( - { id, isDone }, - context -) => { - return context.entities.Task.update({ - where: { id }, - data: { - isDone: isDone, - }, - }) -} -``` - -APPEND `src/MainPage.tsx` - -```tsx -import { FormEvent, ChangeEvent } from 'react' -import { Task } from 'wasp/entities' -import { - updateTask, - createTask, - getTasks, - useQuery, -} from 'wasp/client/operations' - - -// ... MainPage ... - -const TaskView = ({ task }: { task: Task }) => { - const handleIsDoneChange = async (event: ChangeEvent) => { - try { - await updateTask({ - id: task.id, - isDone: event.target.checked, - }) - } catch (error: any) { - window.alert('Error while updating task: ' + error.message) - } - } - - return ( -
- - {task.description} -
- ) -} - -// ... TaskList, NewTaskForm ... -``` - -APPEND `schema.prisma` - -```prisma -model User { - id Int @id @default(autoincrement()) -} -``` - -APPEND `main.wasp` - -```wasp -app TodoApp { - wasp: { - version: "^0.16.0" - }, - title: "TodoApp", - auth: { - // Tells Wasp which entity to use for storing users. - userEntity: User, - methods: { - // Enable username and password auth. - usernameAndPassword: {} - }, - // We'll see how this is used in a bit. - onAuthFailedRedirectTo: "/login" - } -} - -// ... -``` - -RUN wasp db migrate-dev - -APPEND `main.wasp` - -```wasp -route SignupRoute { path: "/signup", to: SignupPage } -page SignupPage { - component: import { SignupPage } from "@src/SignupPage" -} - -route LoginRoute { path: "/login", to: LoginPage } -page LoginPage { - component: import { LoginPage } from "@src/LoginPage" -} -``` - -SET `src/SignupPage.tsx` - -```tsx -import { Link } from 'react-router-dom' -import { SignupForm } from 'wasp/client/auth' - -export const SignupPage = () => { - return ( -
- -
- - I already have an account (go to login). - -
- ) -} -``` - -SET `src/LoginPage.tsx` - -```tsx -import { Link } from 'react-router-dom' -import { LoginForm } from 'wasp/client/auth' - -export const LoginPage = () => { - return ( -
- -
- - I don't have an account yet (go to signup). - -
- ) -} -``` - -APPEND `main.wasp` - -```wasp -// ... - -page MainPage { - authRequired: true, - component: import { MainPage } from "@src/MainPage" -} -``` - -SET `src/MainPage.tsx` - -```tsx -import { AuthUser } from 'wasp/auth' - -export const MainPage = ({ user }: { user: AuthUser }) => { - // Do something with the user - // ... -} -``` - -APPEND `schema.prisma` - -```prisma -// ... - -model User { - id Int @id @default(autoincrement()) - tasks Task[] -} - -model Task { - id Int @id @default(autoincrement()) - description String - isDone Boolean @default(false) - user User? @relation(fields: [userId], references: [id]) - userId Int? -} -``` - -RUN wasp db migrate-dev - -SET `src/queries.ts` - -```ts -import { Task } from 'wasp/entities' -import { HttpError } from 'wasp/server' -import { GetTasks } from 'wasp/server/operations' - -export const getTasks: GetTasks = async (args, context) => { - if (!context.user) { - throw new HttpError(401) - } - return context.entities.Task.findMany({ - where: { user: { id: context.user.id } }, - orderBy: { id: 'asc' }, - }) -} -``` - -SET `src/actions.ts` - -```ts -import { Task } from 'wasp/entities' -import { HttpError } from 'wasp/server' -import { CreateTask, UpdateTask } from 'wasp/server/operations' - -type CreateTaskPayload = Pick - -export const createTask: CreateTask = async ( - args, - context -) => { - if (!context.user) { - throw new HttpError(401) - } - return context.entities.Task.create({ - data: { - description: args.description, - user: { connect: { id: context.user.id } }, - }, - }) -} - -type UpdateTaskPayload = Pick - -export const updateTask: UpdateTask< - UpdateTaskPayload, - { count: number } -> = async ({ id, isDone }, context) => { - if (!context.user) { - throw new HttpError(401) - } - return context.entities.Task.updateMany({ - where: { id, user: { id: context.user.id } }, - data: { isDone }, - }) -} -``` - -APPEND `src/MainPage.tsx` - -```tsx -// ... -import { logout } from 'wasp/client/auth' -//... - -const MainPage = () => { - // ... - return ( -
- // ... - -
- ) -} -``` \ No newline at end of file From a7d35a6cd0d0bdb54677cfb6f23b29502b411db3 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 16 May 2025 18:53:19 +0200 Subject: [PATCH 03/48] Parse diffs --- web/tutorial/package-lock.json | 7 +++++ web/tutorial/package.json | 1 + web/tutorial/src/actions/diff.ts | 30 +++++++++++++++++++ .../src/{actions.ts => actions/index.ts} | 14 ++++++--- web/tutorial/src/index.ts | 20 +++++++++---- web/tutorial/src/markdown/extractSteps.ts | 9 ++---- web/tutorial/src/paths.ts | 6 ++++ 7 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 web/tutorial/src/actions/diff.ts rename web/tutorial/src/{actions.ts => actions/index.ts} (70%) diff --git a/web/tutorial/package-lock.json b/web/tutorial/package-lock.json index 1cd7114b6b..1041a5b3ff 100644 --- a/web/tutorial/package-lock.json +++ b/web/tutorial/package-lock.json @@ -19,6 +19,7 @@ "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-markdown": "^2.1.2", "micromark-extension-mdx-jsx": "^3.0.2", + "parse-git-diff": "^0.0.17", "remark-comment": "^1.0.0", "unist-util-visit": "^5.0.0", "zx": "^8.5.3" @@ -1563,6 +1564,12 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-git-diff": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/parse-git-diff/-/parse-git-diff-0.0.17.tgz", + "integrity": "sha512-Y9oguTLoWJOeGwOeaFP1SxaSIaIp3VtEm7NHHg8Dagaa4fQt2MwFHxdQBLk0LPseuFTgxOs9T+O+uS8Oe5oqEw==", + "license": "MIT" + }, "node_modules/remark-comment": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/remark-comment/-/remark-comment-1.0.0.tgz", diff --git a/web/tutorial/package.json b/web/tutorial/package.json index efa4a5c599..2b78cec2a5 100644 --- a/web/tutorial/package.json +++ b/web/tutorial/package.json @@ -20,6 +20,7 @@ "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-markdown": "^2.1.2", "micromark-extension-mdx-jsx": "^3.0.2", + "parse-git-diff": "^0.0.17", "remark-comment": "^1.0.0", "unist-util-visit": "^5.0.0", "zx": "^8.5.3" diff --git a/web/tutorial/src/actions/diff.ts b/web/tutorial/src/actions/diff.ts new file mode 100644 index 0000000000..08542b6b2c --- /dev/null +++ b/web/tutorial/src/actions/diff.ts @@ -0,0 +1,30 @@ +import type { ApplyPatchAction } from './index' +import parseGitDiff from 'parse-git-diff' + +export function createApplyPatchAction( + patch: string, + step: number +): ApplyPatchAction { + const parsedPatch = parseGitDiff(patch) + + if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { + throw new Error('Invalid patch: no changes found') + } + + if (parsedPatch.files.length > 1) { + throw new Error('Invalid patch: multiple files changed') + } + + if (parsedPatch.files[0].type !== 'ChangedFile') { + throw new Error('Invalid patch: only file changes are supported') + } + + const path = parsedPatch.files[0].path + + return { + kind: 'diff', + patch, + step, + path, + } +} diff --git a/web/tutorial/src/actions.ts b/web/tutorial/src/actions/index.ts similarity index 70% rename from web/tutorial/src/actions.ts rename to web/tutorial/src/actions/index.ts index 53823c0681..e8da8197c2 100644 --- a/web/tutorial/src/actions.ts +++ b/web/tutorial/src/actions/index.ts @@ -3,8 +3,9 @@ import path from 'path' import { $ } from 'zx' -import { appDir, patchesDir } from './paths' -import { log } from './log' +import { appDir, patchesDir } from '../paths' +import { log } from '../log' +import { waspDbMigrate } from '../waspCli' type ActionCommon = { step: number @@ -19,6 +20,7 @@ export type WriteFileAction = { export type ApplyPatchAction = { kind: 'diff' patch: string + path: string } & ActionCommon export type MigrateDbAction = { @@ -36,6 +38,10 @@ export async function writeFileToAppDir(file: WriteFileAction) { export async function applyPatch(patch: ApplyPatchAction) { const patchPath = path.resolve(patchesDir, `step-${patch.step}.patch`) await fs.writeFile(patchPath, patch.patch) - await $`cd ${appDir} && git apply ${patchPath} --verbose` - log('info', `Applied patch for step ${patch.step}`) + await $`cd ${appDir} && git apply ${patchPath} --verbose`.quiet(true) + log('info', `Applied patch to ${patch.path}`) } + +export const migrateDb = waspDbMigrate + +export { createApplyPatchAction } from './diff' diff --git a/web/tutorial/src/index.ts b/web/tutorial/src/index.ts index c11b2d68f1..c74d8b8104 100644 --- a/web/tutorial/src/index.ts +++ b/web/tutorial/src/index.ts @@ -2,9 +2,14 @@ import path from 'path' import { $, chalk } from 'zx' -import { applyPatch, writeFileToAppDir, type Action } from './actions' -import { appDir } from './paths' -import { waspDbMigrate, waspNew } from './waspCli' +import { + applyPatch, + migrateDb, + writeFileToAppDir, + type Action, +} from './actions/index' +import { appDir, ensureDirExists, patchesDir } from './paths' +import { waspNew } from './waspCli' import { log } from './log' import { getActionsFromTutorialFiles } from './markdown/extractSteps' import { program } from '@commander-js/extra-typings' @@ -36,6 +41,7 @@ const actions: Action[] = await getActionsFromTutorialFiles() async function prepareApp() { await $`rm -rf ${appDir}` await waspNew(appDir) + // TODO: Maybe we should have a whitelist of files we want to keep in src? await $`rm ${path.join(appDir, 'src/Main.css')}` await $`rm ${path.join(appDir, 'src/waspLogo.png')}` await $`rm ${path.join(appDir, 'src/MainPage.jsx')}` @@ -54,9 +60,13 @@ for (const action of actions) { const kind = action.kind log('info', `${chalk.bold(`Step ${action.step}`)}: ${kind}`) + // Prepare the patches directory + await ensureDirExists(patchesDir) + try { switch (kind) { case 'diff': + // TODO: Implement edit mode which would make it easier to edit diffs if (options.edit) { // Ask the user if they want to change the diff // If yes, don't apply the diff, let them edit manually and generate a new diff @@ -69,13 +79,13 @@ for (const action of actions) { await writeFileToAppDir(action) break case 'migrate-db': - await waspDbMigrate(`step-${action.step}`) + await migrateDb(`step-${action.step}`) break default: kind satisfies never } } catch (err) { - log('error', `Error in step ${action.step}: ${err}`) + log('error', `Error in step ${action.step}:\n\n${err}`) process.exit(1) } } diff --git a/web/tutorial/src/markdown/extractSteps.ts b/web/tutorial/src/markdown/extractSteps.ts index dadb13391c..c8597d1c23 100644 --- a/web/tutorial/src/markdown/extractSteps.ts +++ b/web/tutorial/src/markdown/extractSteps.ts @@ -6,7 +6,8 @@ import { mdxJsx } from 'micromark-extension-mdx-jsx' import { fromMarkdown } from 'mdast-util-from-markdown' import { mdxJsxFromMarkdown, type MdxJsxFlowElement } from 'mdast-util-mdx-jsx' import { visit } from 'unist-util-visit' -import type { Action } from '../actions' + +import { type Action, createApplyPatchAction } from '../actions/index' import searchAndReplace from '../../../src/remark/search-and-replace.js' const componentName = 'TutorialAction' @@ -78,11 +79,7 @@ async function getActionsFromFile(filePath: string): Promise { const codeBlockCode = childCode.value if (action === 'diff') { - actions.push({ - kind: 'diff', - patch: codeBlockCode, - step, - }) + actions.push(createApplyPatchAction(codeBlockCode, step)) } else if (action === 'write') { const path = getAttributeValue(node, 'path') if (!path) { diff --git a/web/tutorial/src/paths.ts b/web/tutorial/src/paths.ts index b8b12e52c7..552c2a9459 100644 --- a/web/tutorial/src/paths.ts +++ b/web/tutorial/src/paths.ts @@ -1,2 +1,8 @@ +import { promises as fs } from 'fs' + export const appDir = 'TodoApp' export const patchesDir = 'patches' + +export async function ensureDirExists(dir: string): Promise { + await fs.mkdir(dir, { recursive: true }) +} From c29048edaf2d0f1de3c3752e9c64109c0c19f82f Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 19 May 2025 17:07:30 +0200 Subject: [PATCH 04/48] Edit diff DX --- web/docs/tutorial/06-actions.md | 103 ++++++++++++---------- web/docs/tutorial/07-auth.md | 50 ++++++----- web/docs/tutorial/patches/step-10.patch | 48 ++++++++++ web/docs/tutorial/patches/step-11.patch | 12 +++ web/docs/tutorial/patches/step-12.patch | 13 +++ web/docs/tutorial/patches/step-13.patch | 32 +++++++ web/docs/tutorial/patches/step-14.patch | 44 +++++++++ web/docs/tutorial/patches/step-15.patch | 15 ++++ web/docs/tutorial/patches/step-16.patch | 23 +++++ web/docs/tutorial/patches/step-18.patch | 18 ++++ web/docs/tutorial/patches/step-21.patch | 12 +++ web/docs/tutorial/patches/step-22.patch | 15 ++++ web/docs/tutorial/patches/step-23.patch | 20 +++++ web/docs/tutorial/patches/step-25.patch | 21 +++++ web/docs/tutorial/patches/step-26.patch | 49 ++++++++++ web/docs/tutorial/patches/step-27.patch | 22 +++++ web/docs/tutorial/patches/step-3.patch | 14 +++ web/docs/tutorial/patches/step-5.patch | 22 +++++ web/docs/tutorial/patches/step-8.patch | 12 +++ web/tutorial/package-lock.json | 44 +++++++++ web/tutorial/package.json | 2 +- web/tutorial/src/actions/diff.ts | 6 +- web/tutorial/src/actions/index.ts | 3 +- web/tutorial/src/edit/generate-patch.ts | 11 +++ web/tutorial/src/execute-steps/index.ts | 53 +++++++++++ web/tutorial/src/generate-patch.ts | 30 ------- web/tutorial/src/index.ts | 102 ++++++++++++--------- web/tutorial/src/markdown/extractSteps.ts | 17 +++- 28 files changed, 660 insertions(+), 153 deletions(-) create mode 100644 web/docs/tutorial/patches/step-10.patch create mode 100644 web/docs/tutorial/patches/step-11.patch create mode 100644 web/docs/tutorial/patches/step-12.patch create mode 100644 web/docs/tutorial/patches/step-13.patch create mode 100644 web/docs/tutorial/patches/step-14.patch create mode 100644 web/docs/tutorial/patches/step-15.patch create mode 100644 web/docs/tutorial/patches/step-16.patch create mode 100644 web/docs/tutorial/patches/step-18.patch create mode 100644 web/docs/tutorial/patches/step-21.patch create mode 100644 web/docs/tutorial/patches/step-22.patch create mode 100644 web/docs/tutorial/patches/step-23.patch create mode 100644 web/docs/tutorial/patches/step-25.patch create mode 100644 web/docs/tutorial/patches/step-26.patch create mode 100644 web/docs/tutorial/patches/step-27.patch create mode 100644 web/docs/tutorial/patches/step-3.patch create mode 100644 web/docs/tutorial/patches/step-5.patch create mode 100644 web/docs/tutorial/patches/step-8.patch create mode 100644 web/tutorial/src/edit/generate-patch.ts create mode 100644 web/tutorial/src/execute-steps/index.ts delete mode 100644 web/tutorial/src/generate-patch.ts diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index 1543ae5d58..8cd2f2de24 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -28,13 +28,13 @@ We must first declare the Action in `main.wasp`: ```diff diff --git a/main.wasp b/main.wasp -index ea22c79..42f8f6e 100644 +index b288bf6..e83fe65 100644 --- a/main.wasp +++ b/main.wasp @@ -20,3 +20,7 @@ query getTasks { - entities: [Task] - } - + entities: [Task] + } + +action createTask { + fn: import { createTask } from "@src/actions", + entities: [Task] @@ -94,29 +94,32 @@ Start by defining a form for creating new tasks. ```diff diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 2a12348..a32d3ba 100644 +index 556e1a2..50118d6 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx -@@ -1,5 +1,10 @@ -+import { FormEvent } from 'react' - import { Task } from 'wasp/entities' +@@ -1,6 +1,11 @@ ++import type { FormEvent } from 'react' + import type { Task } from 'wasp/entities' +-// highlight-next-line -import { getTasks, useQuery } from 'wasp/client/operations' +import { ++ // highlight-next-line + createTask, + getTasks, + useQuery, +} from 'wasp/client/operations' - - export const MainPage = () => { - const { data: tasks, isLoading, error } = useQuery(getTasks) -@@ -33,4 +38,25 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { - ))} - - ) --} + + export const MainPage = () => { + // highlight-start +@@ -38,4 +43,27 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { + + ) + } +-// highlight-end \ No newline at end of file -+} ++// highlight-end + ++// highlight-start +const NewTaskForm = () => { + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() @@ -137,6 +140,7 @@ index 2a12348..a32d3ba 100644 + + ) +} ++// highlight-end ``` @@ -190,18 +194,17 @@ All that's left now is adding this form to the page component: ```diff diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index a32d3ba..c075f7d 100644 +index 50118d6..296d0cf 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx -@@ -11,6 +11,8 @@ export const MainPage = () => { - - return ( -
+@@ -13,6 +13,7 @@ export const MainPage = () => { + + return ( +
+ -+ - {tasks && } - - {isLoading && 'Loading...'} + {tasks && } + + {isLoading && 'Loading...'} ``` @@ -266,13 +269,13 @@ Since we've already created one task together, try to create this one yourself. ```diff diff --git a/main.wasp b/main.wasp - index 42f8f6e..bffede4 100644 + index e83fe65..31483d2 100644 --- a/main.wasp +++ b/main.wasp @@ -24,3 +24,8 @@ action createTask { - fn: import { createTask } from "@src/actions", - entities: [Task] - } + fn: import { createTask } from "@src/actions", + entities: [Task] + } + +action updateTask { + fn: import { updateTask } from "@src/actions", @@ -361,22 +364,24 @@ You can now call `updateTask` from the React component: ```diff diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index a6997e0..fc436c9 100644 +index 296d0cf..f02e05e 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx -@@ -1,6 +1,7 @@ --import { FormEvent } from 'react' -+import { FormEvent, ChangeEvent } from 'react' - import { Task } from 'wasp/entities' - import { +@@ -1,7 +1,8 @@ +-import type { FormEvent } from 'react' ++import type { FormEvent, ChangeEvent } from 'react' + import type { Task } from 'wasp/entities' + import { + // highlight-next-line + updateTask, - createTask, - getTasks, - useQuery, -@@ -22,9 +23,25 @@ export const MainPage = () => { - } - - const TaskView = ({ task }: { task: Task }) => { + createTask, + getTasks, + useQuery, +@@ -25,9 +26,28 @@ export const MainPage = () => { + + // highlight-start + const TaskView = ({ task }: { task: Task }) => { ++ // highlight-start + const handleIsDoneChange = async (event: ChangeEvent) => { + try { + await updateTask({ @@ -387,19 +392,21 @@ index a6997e0..fc436c9 100644 + window.alert('Error while updating task: ' + error.message) + } + } ++ // highlight-end + - return ( -
+ return ( +
- + - {task.description} -
- ) + {task.description} +
+ ) ``` diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 85dcf3733f..f4531bfecd 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -272,20 +272,20 @@ Additionally, when `authRequired` is `true`, the page's React component will be ```diff diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 0f58592..bb64039 100644 +index f02e05e..a4389aa 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx -@@ -6,8 +6,9 @@ import { - getTasks, - useQuery, - } from 'wasp/client/operations' +@@ -7,8 +7,9 @@ import { + getTasks, + useQuery, + } from 'wasp/client/operations' +import { AuthUser } from 'wasp/auth' - + -export const MainPage = () => { +export const MainPage = ({ user }: { user: AuthUser }) => { - const { data: tasks, isLoading, error } = useQuery(getTasks) - - return ( + // highlight-start + const { data: tasks, isLoading, error } = useQuery(getTasks) + ``` @@ -566,25 +566,27 @@ Last, but not least, let's add the logout functionality: ```diff diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index bb64039..abe5672 100644 +index a4389aa..b1f94f5 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx -@@ -7,6 +7,7 @@ import { - useQuery, - } from 'wasp/client/operations' - import { AuthUser } from 'wasp/auth' +@@ -7,7 +7,8 @@ import { + getTasks, + useQuery, + } from 'wasp/client/operations' +-+import { AuthUser } from 'wasp/auth' ++import { AuthUser } from 'wasp/auth' +import { logout } from 'wasp/client/auth' - - export const MainPage = ({ user }: { user: AuthUser }) => { - const { data: tasks, isLoading, error } = useQuery(getTasks) -@@ -19,6 +20,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { - - {isLoading && 'Loading...'} - {error && 'Error: ' + error} + + export const MainPage = ({ user }: { user: AuthUser }) => { + // highlight-start +@@ -20,6 +21,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { + + {isLoading && 'Loading...'} + {error && 'Error: ' + error} + -
- ) - } +
+ ) + // highlight-end ``` diff --git a/web/docs/tutorial/patches/step-10.patch b/web/docs/tutorial/patches/step-10.patch new file mode 100644 index 0000000000..885aa2e4d7 --- /dev/null +++ b/web/docs/tutorial/patches/step-10.patch @@ -0,0 +1,48 @@ +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +index 556e1a2..50118d6 100644 +--- a/src/MainPage.tsx ++++ b/src/MainPage.tsx +@@ -1,6 +1,11 @@ ++import type { FormEvent } from 'react' + import type { Task } from 'wasp/entities' +-// highlight-next-line +-import { getTasks, useQuery } from 'wasp/client/operations' ++import { ++ // highlight-next-line ++ createTask, ++ getTasks, ++ useQuery, ++} from 'wasp/client/operations' + + export const MainPage = () => { + // highlight-start +@@ -38,4 +43,27 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { + + ) + } +-// highlight-end +\ No newline at end of file ++// highlight-end ++ ++// highlight-start ++const NewTaskForm = () => { ++ const handleSubmit = async (event: FormEvent) => { ++ event.preventDefault() ++ try { ++ const target = event.target as HTMLFormElement ++ const description = target.description.value ++ target.reset() ++ await createTask({ description }) ++ } catch (err: any) { ++ window.alert('Error: ' + err.message) ++ } ++ } ++ ++ return ( ++
++ ++ ++
++ ) ++} ++// highlight-end diff --git a/web/docs/tutorial/patches/step-11.patch b/web/docs/tutorial/patches/step-11.patch new file mode 100644 index 0000000000..42b0e75d10 --- /dev/null +++ b/web/docs/tutorial/patches/step-11.patch @@ -0,0 +1,12 @@ +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +index 50118d6..296d0cf 100644 +--- a/src/MainPage.tsx ++++ b/src/MainPage.tsx +@@ -13,6 +13,7 @@ export const MainPage = () => { + + return ( +
++ + {tasks && } + + {isLoading && 'Loading...'} diff --git a/web/docs/tutorial/patches/step-12.patch b/web/docs/tutorial/patches/step-12.patch new file mode 100644 index 0000000000..3ffa450a8d --- /dev/null +++ b/web/docs/tutorial/patches/step-12.patch @@ -0,0 +1,13 @@ +diff --git a/main.wasp b/main.wasp +index e83fe65..31483d2 100644 +--- a/main.wasp ++++ b/main.wasp +@@ -24,3 +24,8 @@ action createTask { + fn: import { createTask } from "@src/actions", + entities: [Task] +} ++ ++action updateTask { ++ fn: import { updateTask } from "@src/actions", ++ entities: [Task] ++} diff --git a/web/docs/tutorial/patches/step-13.patch b/web/docs/tutorial/patches/step-13.patch new file mode 100644 index 0000000000..5d864a5fc1 --- /dev/null +++ b/web/docs/tutorial/patches/step-13.patch @@ -0,0 +1,32 @@ +diff --git a/src/actions.ts b/src/actions.ts +index 3edb8fb..45c82eb 100644 +--- a/src/actions.ts ++++ b/src/actions.ts +@@ -1,5 +1,5 @@ + import { Task } from 'wasp/entities' +-import { CreateTask } from 'wasp/server/operations' ++import { CreateTask, UpdateTask } from 'wasp/server/operations' + + type CreateTaskPayload = Pick + +@@ -10,4 +10,18 @@ export const createTask: CreateTask = async ( + return context.entities.Task.create({ + data: { description: args.description }, + }) +-} +\ No newline at end of file ++} ++ ++type UpdateTaskPayload = Pick ++ ++export const updateTask: UpdateTask = async ( ++ { id, isDone }, ++ context ++) => { ++ return context.entities.Task.update({ ++ where: { id }, ++ data: { ++ isDone: isDone, ++ }, ++ }) ++} diff --git a/web/docs/tutorial/patches/step-14.patch b/web/docs/tutorial/patches/step-14.patch new file mode 100644 index 0000000000..2567a0e0a7 --- /dev/null +++ b/web/docs/tutorial/patches/step-14.patch @@ -0,0 +1,44 @@ +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +index 296d0cf..f02e05e 100644 +--- a/src/MainPage.tsx ++++ b/src/MainPage.tsx +@@ -1,7 +1,8 @@ +-import type { FormEvent } from 'react' ++import type { FormEvent, ChangeEvent } from 'react' + import type { Task } from 'wasp/entities' + import { + // highlight-next-line ++ updateTask, + createTask, + getTasks, + useQuery, +@@ -25,9 +26,28 @@ export const MainPage = () => { + + // highlight-start + const TaskView = ({ task }: { task: Task }) => { ++ // highlight-start ++ const handleIsDoneChange = async (event: ChangeEvent) => { ++ try { ++ await updateTask({ ++ id: task.id, ++ isDone: event.target.checked, ++ }) ++ } catch (error: any) { ++ window.alert('Error while updating task: ' + error.message) ++ } ++ } ++ // highlight-end ++ + return ( +
+- ++ + {task.description} +
+ ) diff --git a/web/docs/tutorial/patches/step-15.patch b/web/docs/tutorial/patches/step-15.patch new file mode 100644 index 0000000000..2d6393fcfa --- /dev/null +++ b/web/docs/tutorial/patches/step-15.patch @@ -0,0 +1,15 @@ +diff --git a/schema.prisma b/schema.prisma +index 65d7d30..e07f47c 100644 +--- a/schema.prisma ++++ b/schema.prisma +@@ -9,6 +9,10 @@ generator client { + provider = "prisma-client-js" + } + ++model User { ++ id Int @id @default(autoincrement()) ++} ++ + model Task { + id Int @id @default(autoincrement()) + description String diff --git a/web/docs/tutorial/patches/step-16.patch b/web/docs/tutorial/patches/step-16.patch new file mode 100644 index 0000000000..f529ece110 --- /dev/null +++ b/web/docs/tutorial/patches/step-16.patch @@ -0,0 +1,23 @@ +diff --git a/main.wasp b/main.wasp +index 42f8f6e..9b61df0 100644 +--- a/main.wasp ++++ b/main.wasp +@@ -2,7 +2,17 @@ app TodoApp { + wasp: { + version: "^0.16.3" + }, +- title: "TodoApp" ++ title: "TodoApp", ++ auth: { ++ // Tells Wasp which entity to use for storing users. ++ userEntity: User, ++ methods: { ++ // Enable username and password auth. ++ usernameAndPassword: {} ++ }, ++ // We'll see how this is used in a bit. ++ onAuthFailedRedirectTo: "/login" ++ } + } + + route RootRoute { path: "/", to: MainPage } diff --git a/web/docs/tutorial/patches/step-18.patch b/web/docs/tutorial/patches/step-18.patch new file mode 100644 index 0000000000..e4b287df79 --- /dev/null +++ b/web/docs/tutorial/patches/step-18.patch @@ -0,0 +1,18 @@ +diff --git a/main.wasp b/main.wasp +index 4dccb7d..12a0895 100644 +--- a/main.wasp ++++ b/main.wasp +@@ -39,3 +39,13 @@ action updateTask { + fn: import { updateTask } from "@src/actions", + entities: [Task] + } ++ ++route SignupRoute { path: "/signup", to: SignupPage } ++page SignupPage { ++ component: import { SignupPage } from "@src/SignupPage" ++} ++ ++route LoginRoute { path: "/login", to: LoginPage } ++page LoginPage { ++ component: import { LoginPage } from "@src/LoginPage" ++} diff --git a/web/docs/tutorial/patches/step-21.patch b/web/docs/tutorial/patches/step-21.patch new file mode 100644 index 0000000000..3bc5532055 --- /dev/null +++ b/web/docs/tutorial/patches/step-21.patch @@ -0,0 +1,12 @@ +diff --git a/main.wasp b/main.wasp +index 12a0895..c621b88 100644 +--- a/main.wasp ++++ b/main.wasp +@@ -17,6 +17,7 @@ app TodoApp { + + route RootRoute { path: "/", to: MainPage } + page MainPage { ++ authRequired: true, + component: import { MainPage } from "@src/MainPage" + } + diff --git a/web/docs/tutorial/patches/step-22.patch b/web/docs/tutorial/patches/step-22.patch new file mode 100644 index 0000000000..47e9a8350c --- /dev/null +++ b/web/docs/tutorial/patches/step-22.patch @@ -0,0 +1,15 @@ +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +index f02e05e..a4389aa 100644 +--- a/src/MainPage.tsx ++++ b/src/MainPage.tsx +@@ -7,8 +7,9 @@ import { + getTasks, + useQuery, + } from 'wasp/client/operations' ++import { AuthUser } from 'wasp/auth' + +-export const MainPage = () => { ++export const MainPage = ({ user }: { user: AuthUser }) => { + // highlight-start + const { data: tasks, isLoading, error } = useQuery(getTasks) + diff --git a/web/docs/tutorial/patches/step-23.patch b/web/docs/tutorial/patches/step-23.patch new file mode 100644 index 0000000000..bce380d8aa --- /dev/null +++ b/web/docs/tutorial/patches/step-23.patch @@ -0,0 +1,20 @@ +diff --git a/schema.prisma b/schema.prisma +index e07f47c..aad0a3c 100644 +--- a/schema.prisma ++++ b/schema.prisma +@@ -10,11 +10,14 @@ generator client { + } + + model User { +- id Int @id @default(autoincrement()) ++ id Int @id @default(autoincrement()) ++ tasks Task[] + } + + model Task { + id Int @id @default(autoincrement()) + description String + isDone Boolean @default(false) ++ user User? @relation(fields: [userId], references: [id]) ++ userId Int? + } diff --git a/web/docs/tutorial/patches/step-25.patch b/web/docs/tutorial/patches/step-25.patch new file mode 100644 index 0000000000..832d1859c2 --- /dev/null +++ b/web/docs/tutorial/patches/step-25.patch @@ -0,0 +1,21 @@ +diff --git a/src/queries.ts b/src/queries.ts +index 49d17ec..dc744eb 100644 +--- a/src/queries.ts ++++ b/src/queries.ts +@@ -1,8 +1,13 @@ + import { Task } from 'wasp/entities' +-import { type GetTasks } from 'wasp/server/operations' ++import { HttpError } from 'wasp/server' ++import { GetTasks } from 'wasp/server/operations' + + export const getTasks: GetTasks = async (args, context) => { ++ if (!context.user) { ++ throw new HttpError(401) ++ } + return context.entities.Task.findMany({ ++ where: { user: { id: context.user.id } }, + orderBy: { id: 'asc' }, + }) +-} +\ No newline at end of file ++} diff --git a/web/docs/tutorial/patches/step-26.patch b/web/docs/tutorial/patches/step-26.patch new file mode 100644 index 0000000000..e25c8ae00f --- /dev/null +++ b/web/docs/tutorial/patches/step-26.patch @@ -0,0 +1,49 @@ +diff --git a/src/actions.ts b/src/actions.ts +index 45c82eb..1a0d088 100644 +--- a/src/actions.ts ++++ b/src/actions.ts +@@ -1,4 +1,5 @@ + import { Task } from 'wasp/entities' ++import { HttpError } from 'wasp/server' + import { CreateTask, UpdateTask } from 'wasp/server/operations' + + type CreateTaskPayload = Pick +@@ -7,21 +8,28 @@ export const createTask: CreateTask = async ( + args, + context + ) => { ++ if (!context.user) { ++ throw new HttpError(401) ++ } + return context.entities.Task.create({ +- data: { description: args.description }, ++ data: { ++ description: args.description, ++ user: { connect: { id: context.user.id } }, ++ }, + }) + } + + type UpdateTaskPayload = Pick + +-export const updateTask: UpdateTask = async ( +- { id, isDone }, +- context +-) => { +- return context.entities.Task.update({ +- where: { id }, +- data: { +- isDone: isDone, +- }, ++export const updateTask: UpdateTask< ++ UpdateTaskPayload, ++ { count: number } ++> = async ({ id, isDone }, context) => { ++ if (!context.user) { ++ throw new HttpError(401) ++ } ++ return context.entities.Task.updateMany({ ++ where: { id, user: { id: context.user.id } }, ++ data: { isDone }, + }) + } diff --git a/web/docs/tutorial/patches/step-27.patch b/web/docs/tutorial/patches/step-27.patch new file mode 100644 index 0000000000..93f26deb7f --- /dev/null +++ b/web/docs/tutorial/patches/step-27.patch @@ -0,0 +1,22 @@ +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +index a4389aa..b1f94f5 100644 +--- a/src/MainPage.tsx ++++ b/src/MainPage.tsx +@@ -7,7 +7,8 @@ import { + getTasks, + useQuery, + } from 'wasp/client/operations' +-+import { AuthUser } from 'wasp/auth' ++import { AuthUser } from 'wasp/auth' ++import { logout } from 'wasp/client/auth' + + export const MainPage = ({ user }: { user: AuthUser }) => { + // highlight-start +@@ -20,6 +21,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { + + {isLoading && 'Loading...'} + {error && 'Error: ' + error} ++ +
+ ) + // highlight-end diff --git a/web/docs/tutorial/patches/step-3.patch b/web/docs/tutorial/patches/step-3.patch new file mode 100644 index 0000000000..6e0d47c74a --- /dev/null +++ b/web/docs/tutorial/patches/step-3.patch @@ -0,0 +1,14 @@ +diff --git a/schema.prisma b/schema.prisma +index 190e2a8..65d7d30 100644 +--- a/schema.prisma ++++ b/schema.prisma +@@ -8,3 +8,9 @@ datasource db { + generator client { + provider = "prisma-client-js" + } ++ ++model Task { ++ id Int @id @default(autoincrement()) ++ description String ++ isDone Boolean @default(false) ++} diff --git a/web/docs/tutorial/patches/step-5.patch b/web/docs/tutorial/patches/step-5.patch new file mode 100644 index 0000000000..71374439ad --- /dev/null +++ b/web/docs/tutorial/patches/step-5.patch @@ -0,0 +1,22 @@ +diff --git a/main.wasp b/main.wasp +index 3a25ea9..ea22c79 100644 +--- a/main.wasp ++++ b/main.wasp +@@ -8,4 +8,15 @@ app TodoApp { + route RootRoute { path: "/", to: MainPage } + page MainPage { + component: import { MainPage } from "@src/MainPage" +-} +\ No newline at end of file ++} ++ ++query getTasks { ++ // Specifies where the implementation for the query function is. ++ // The path `@src/queries` resolves to `src/queries.ts`. ++ // No need to specify an extension. ++ fn: import { getTasks } from "@src/queries", ++ // Tell Wasp that this query reads from the `Task` entity. Wasp will ++ // automatically update the results of this query when tasks are modified. ++ entities: [Task] ++} ++ diff --git a/web/docs/tutorial/patches/step-8.patch b/web/docs/tutorial/patches/step-8.patch new file mode 100644 index 0000000000..f8a1eab9d5 --- /dev/null +++ b/web/docs/tutorial/patches/step-8.patch @@ -0,0 +1,12 @@ +diff --git a/main.wasp b/main.wasp +index b288bf6..e83fe65 100644 +--- a/main.wasp ++++ b/main.wasp +@@ -20,3 +20,7 @@ query getTasks { + entities: [Task] + } + ++action createTask { ++ fn: import { createTask } from "@src/actions", ++ entities: [Task] ++} diff --git a/web/tutorial/package-lock.json b/web/tutorial/package-lock.json index 1041a5b3ff..e970a77e25 100644 --- a/web/tutorial/package-lock.json +++ b/web/tutorial/package-lock.json @@ -13,6 +13,7 @@ "acorn": "^8.14.1", "commander": "^13.1.0", "dedent": "^1.6.0", + "enquirer": "^2.4.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-frontmatter": "^2.0.1", "mdast-util-mdx": "^3.0.0", @@ -529,6 +530,24 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -654,6 +673,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/esbuild": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", @@ -1677,6 +1709,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tsx": { "version": "4.19.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", diff --git a/web/tutorial/package.json b/web/tutorial/package.json index 2b78cec2a5..7eabeff548 100644 --- a/web/tutorial/package.json +++ b/web/tutorial/package.json @@ -5,7 +5,6 @@ "type": "module", "scripts": { "start": "tsx ./src/index.ts", - "generate-patch": "tsx ./src/generate-patch.ts", "parse-markdown": "tsx ./src/markdown/index.ts" }, "license": "MIT", @@ -14,6 +13,7 @@ "acorn": "^8.14.1", "commander": "^13.1.0", "dedent": "^1.6.0", + "enquirer": "^2.4.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-frontmatter": "^2.0.1", "mdast-util-mdx": "^3.0.0", diff --git a/web/tutorial/src/actions/diff.ts b/web/tutorial/src/actions/diff.ts index 08542b6b2c..7f3dee7126 100644 --- a/web/tutorial/src/actions/diff.ts +++ b/web/tutorial/src/actions/diff.ts @@ -1,9 +1,9 @@ -import type { ApplyPatchAction } from './index' +import type { ActionCommon, ApplyPatchAction } from './index' import parseGitDiff from 'parse-git-diff' export function createApplyPatchAction( patch: string, - step: number + commonActionData: ActionCommon ): ApplyPatchAction { const parsedPatch = parseGitDiff(patch) @@ -22,9 +22,9 @@ export function createApplyPatchAction( const path = parsedPatch.files[0].path return { + ...commonActionData, kind: 'diff', patch, - step, path, } } diff --git a/web/tutorial/src/actions/index.ts b/web/tutorial/src/actions/index.ts index e8da8197c2..439dc0ff1d 100644 --- a/web/tutorial/src/actions/index.ts +++ b/web/tutorial/src/actions/index.ts @@ -7,8 +7,9 @@ import { appDir, patchesDir } from '../paths' import { log } from '../log' import { waspDbMigrate } from '../waspCli' -type ActionCommon = { +export type ActionCommon = { step: number + markdownSourceFilePath: string } export type WriteFileAction = { diff --git a/web/tutorial/src/edit/generate-patch.ts b/web/tutorial/src/edit/generate-patch.ts new file mode 100644 index 0000000000..3a89daa29c --- /dev/null +++ b/web/tutorial/src/edit/generate-patch.ts @@ -0,0 +1,11 @@ +import { $ } from 'zx' +import { appDir } from '../paths' + +export async function makeCheckpoint(): Promise { + await $`cd ${appDir} && git add . && git commit -m "checkpoint"` +} + +export async function generateGitPatch(): Promise { + const { stdout: patch } = await $`cd ${appDir} && git diff` + return patch +} diff --git a/web/tutorial/src/execute-steps/index.ts b/web/tutorial/src/execute-steps/index.ts new file mode 100644 index 0000000000..cd0df187ae --- /dev/null +++ b/web/tutorial/src/execute-steps/index.ts @@ -0,0 +1,53 @@ +import { chalk } from 'zx' +import { + applyPatch, + migrateDb, + writeFileToAppDir, + type Action, +} from '../actions' +import { log } from '../log' +import { ensureDirExists, patchesDir } from '../paths' + +export async function executeSteps( + actions: Action[], + { + untilStep, + }: { + untilStep?: number + } +): Promise { + for (const action of actions) { + if (untilStep && action.step === untilStep) { + log('info', `Stopping before step ${action.step}`) + process.exit(0) + } + + const kind = action.kind + log('info', `${chalk.bold(`[step ${action.step}]`)} ${kind}`) + + // Prepare the patches directory + await ensureDirExists(patchesDir) + + try { + switch (kind) { + case 'diff': + // TODO: Implement edit mode which would make it easier to edit diffs + + await applyPatch(action) + break + case 'write': + await writeFileToAppDir(action) + break + case 'migrate-db': + await migrateDb(`step-${action.step}`) + break + default: + kind satisfies never + } + } catch (err) { + log('error', `Error in step ${action.step}:\n\n${err}`) + process.exit(1) + } + log('info', 'All done!') + } +} diff --git a/web/tutorial/src/generate-patch.ts b/web/tutorial/src/generate-patch.ts deleted file mode 100644 index e3afef55ee..0000000000 --- a/web/tutorial/src/generate-patch.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { $ } from 'zx' -import readline from 'readline' - -$.verbose = true - -console.log('Committing current state...') -await $`cd ./TodoApp && git add . && git commit -m "checkpoint"` - -console.log('\n==============================================') -console.log('Now make your changes to the TodoApp project.') -console.log('When finished, return here and press Enter to generate the patch.') -console.log('==============================================\n') - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}) - -await new Promise((resolve) => { - rl.question('Press Enter when ready to generate the patch...', () => { - rl.close() - resolve() - }) -}) - -console.log('\n==============================================') - -await $`cd ./TodoApp && git diff` - -console.log('\n==============================================') diff --git a/web/tutorial/src/index.ts b/web/tutorial/src/index.ts index c74d8b8104..a926e516de 100644 --- a/web/tutorial/src/index.ts +++ b/web/tutorial/src/index.ts @@ -2,6 +2,8 @@ import path from 'path' import { $, chalk } from 'zx' +import fs from 'fs/promises' + import { applyPatch, migrateDb, @@ -14,7 +16,14 @@ import { log } from './log' import { getActionsFromTutorialFiles } from './markdown/extractSteps' import { program } from '@commander-js/extra-typings' -const options = program +import Enquirer from 'enquirer' +import { generateGitPatch, makeCheckpoint } from './edit/generate-patch' +import { executeSteps } from './execute-steps' +import { write } from 'fs' + +const actions: Action[] = await getActionsFromTutorialFiles() + +const { editDiff, untilStep } = program .option( '-s, --until-step ', 'Run until the given step. If not provided, run all steps.', @@ -27,17 +36,28 @@ const options = program } ) .option( - '-e, --edit', - 'Edit mode, you will be offered to edit the diffs', - false + '-e, --edit-diff ', + 'Edit mode, you will edit the diff interactively and all the steps related to the same file that come after.', + (value: string) => { + const step = parseInt(value, 10) + if (isNaN(step) || step < 1) { + throw new Error('Step must be a positive integer.') + } + const actionAtStep = actions.find((action) => action.step === step) + if (!actionAtStep) { + throw new Error(`No action found for step ${step}.`) + } + if (actionAtStep.kind !== 'diff') { + throw new Error(`Action at step ${step} is not a diff action.`) + } + return actionAtStep + } ) .parse(process.argv) .opts() $.verbose = true -const actions: Action[] = await getActionsFromTutorialFiles() - async function prepareApp() { await $`rm -rf ${appDir}` await waspNew(appDir) @@ -51,43 +71,41 @@ async function prepareApp() { await prepareApp() -for (const action of actions) { - if (options.untilStep && action.step === options.untilStep) { - log('info', `Stopping before step ${action.step}`) - process.exit(0) - } - - const kind = action.kind - log('info', `${chalk.bold(`Step ${action.step}`)}: ${kind}`) +if (editDiff) { + const actionsBeforeStep = actions.filter( + (action) => action.step < editDiff.step + ) + await executeSteps(actionsBeforeStep, { + untilStep: editDiff.step, + }) + const actionsToEdit = actions + .filter((action) => action.kind === 'diff') + .filter( + (action) => action.path === editDiff.path && action.step >= editDiff.step + ) - // Prepare the patches directory - await ensureDirExists(patchesDir) + // Okay, we are going to edit now all the steps that are related to the same file + // starting with step editDiff.step + console.log( + `We are now going to edit all the steps for file ${editDiff.path} from step ${editDiff.step} onwards` + ) - try { - switch (kind) { - case 'diff': - // TODO: Implement edit mode which would make it easier to edit diffs - if (options.edit) { - // Ask the user if they want to change the diff - // If yes, don't apply the diff, let them edit manually and generate a new diff - // Display the diff to the user - } else { - await applyPatch(action) - } - break - case 'write': - await writeFileToAppDir(action) - break - case 'migrate-db': - await migrateDb(`step-${action.step}`) - break - default: - kind satisfies never - } - } catch (err) { - log('error', `Error in step ${action.step}:\n\n${err}`) - process.exit(1) + for (const action of actionsToEdit) { + const { step, path } = action + await makeCheckpoint() + await Enquirer.prompt({ + type: 'confirm', + name: 'edit', + message: `Apply the new edit to ${path} at step ${step} and press Enter`, + initial: true, + }) + const patch = await generateGitPatch() + console.log('=====================') + console.log(patch) + console.log('=====================') } +} else { + await executeSteps(actions, { + untilStep, + }) } - -log('info', 'All done!') diff --git a/web/tutorial/src/markdown/extractSteps.ts b/web/tutorial/src/markdown/extractSteps.ts index c8597d1c23..18d19804fe 100644 --- a/web/tutorial/src/markdown/extractSteps.ts +++ b/web/tutorial/src/markdown/extractSteps.ts @@ -7,7 +7,11 @@ import { fromMarkdown } from 'mdast-util-from-markdown' import { mdxJsxFromMarkdown, type MdxJsxFlowElement } from 'mdast-util-mdx-jsx' import { visit } from 'unist-util-visit' -import { type Action, createApplyPatchAction } from '../actions/index' +import { + type Action, + type ActionCommon, + createApplyPatchAction, +} from '../actions/index' import searchAndReplace from '../../../src/remark/search-and-replace.js' const componentName = 'TutorialAction' @@ -59,10 +63,15 @@ async function getActionsFromFile(filePath: string): Promise { throw new Error('Step and action attributes are required') } + const commonActionData: ActionCommon = { + step, + markdownSourceFilePath: filePath, + } + if (action === 'migrate-db') { actions.push({ + ...commonActionData, kind: 'migrate-db', - step, }) return } @@ -79,17 +88,17 @@ async function getActionsFromFile(filePath: string): Promise { const codeBlockCode = childCode.value if (action === 'diff') { - actions.push(createApplyPatchAction(codeBlockCode, step)) + actions.push(createApplyPatchAction(codeBlockCode, commonActionData)) } else if (action === 'write') { const path = getAttributeValue(node, 'path') if (!path) { throw new Error('Path attribute is required for write action') } actions.push({ + ...commonActionData, kind: 'write', content: codeBlockCode, path, - step, }) } }) From 373e5632ec4d0efac8eb568f2bc84d9df21dad5e Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 19 May 2025 19:28:02 +0200 Subject: [PATCH 05/48] Move patches outside of Markdown. Implement fixing broken diffs --- web/docs/tutorial/04-entities.md | 22 +- web/docs/tutorial/05-queries.md | 30 +-- web/docs/tutorial/06-actions.md | 209 +---------------- web/docs/tutorial/07-auth.md | 267 +--------------------- web/docs/tutorial/patches/step-12.patch | 6 +- web/docs/tutorial/patches/step-13.patch | 8 +- web/docs/tutorial/patches/step-16.patch | 4 +- web/docs/tutorial/patches/step-18.patch | 8 +- web/docs/tutorial/patches/step-21.patch | 4 +- web/docs/tutorial/patches/step-25.patch | 17 +- web/docs/tutorial/patches/step-26.patch | 48 ++-- web/docs/tutorial/patches/step-27.patch | 8 +- web/tutorial/src/actions/diff.ts | 21 +- web/tutorial/src/actions/index.ts | 14 +- web/tutorial/src/edit/generate-patch.ts | 6 +- web/tutorial/src/edit/index.ts | 45 ++++ web/tutorial/src/execute-steps/index.ts | 3 - web/tutorial/src/index.ts | 54 +---- web/tutorial/src/markdown/extractSteps.ts | 10 +- 19 files changed, 159 insertions(+), 625 deletions(-) create mode 100644 web/tutorial/src/edit/index.ts diff --git a/web/docs/tutorial/04-entities.md b/web/docs/tutorial/04-entities.md index d6e24ab53c..e1038bbda8 100644 --- a/web/docs/tutorial/04-entities.md +++ b/web/docs/tutorial/04-entities.md @@ -11,27 +11,7 @@ Wasp uses Prisma to talk to the database, and you define Entities by defining Pr Since our Todo app is all about tasks, we'll define a Task entity by adding a Task model in the `schema.prisma` file: - - -```diff -diff --git a/schema.prisma b/schema.prisma -index 190e2a8..65d7d30 100644 ---- a/schema.prisma -+++ b/schema.prisma -@@ -8,3 +8,9 @@ datasource db { - generator client { - provider = "prisma-client-js" - } -+ -+model Task { -+ id Int @id @default(autoincrement()) -+ description String -+ isDone Boolean @default(false) -+} - -``` - - + ```prisma title="schema.prisma" // ... diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index c6e6b653c2..d393bc1038 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -43,35 +43,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis - - - ```diff - diff --git a/main.wasp b/main.wasp - index 3a25ea9..ea22c79 100644 - --- a/main.wasp - +++ b/main.wasp - @@ -8,4 +8,15 @@ app TodoApp { - route RootRoute { path: "/", to: MainPage } - page MainPage { - component: import { MainPage } from "@src/MainPage" - -} - \ No newline at end of file - +} - + - +query getTasks { - + // Specifies where the implementation for the query function is. - + // The path `@src/queries` resolves to `src/queries.ts`. - + // No need to specify an extension. - + fn: import { getTasks } from "@src/queries", - + // Tell Wasp that this query reads from the `Task` entity. Wasp will - + // automatically update the results of this query when tasks are modified. - + entities: [Task] - +} - + - - ``` - - + ```wasp title="main.wasp" // ... diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index 8cd2f2de24..d3fbfcea7e 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -24,25 +24,7 @@ Creating an Action is very similar to creating a Query. We must first declare the Action in `main.wasp`: - - -```diff -diff --git a/main.wasp b/main.wasp -index b288bf6..e83fe65 100644 ---- a/main.wasp -+++ b/main.wasp -@@ -20,3 +20,7 @@ query getTasks { - entities: [Task] - } - -+action createTask { -+ fn: import { createTask } from "@src/actions", -+ entities: [Task] -+} - -``` - - + ```wasp title="main.wasp" // ... @@ -90,61 +72,7 @@ We put the function in a new file `src/actions.{js,ts}`, but we could have put i Start by defining a form for creating new tasks. - - -```diff -diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 556e1a2..50118d6 100644 ---- a/src/MainPage.tsx -+++ b/src/MainPage.tsx -@@ -1,6 +1,11 @@ -+import type { FormEvent } from 'react' - import type { Task } from 'wasp/entities' --// highlight-next-line --import { getTasks, useQuery } from 'wasp/client/operations' -+import { -+ // highlight-next-line -+ createTask, -+ getTasks, -+ useQuery, -+} from 'wasp/client/operations' - - export const MainPage = () => { - // highlight-start -@@ -38,4 +43,27 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { - - ) - } --// highlight-end -\ No newline at end of file -+// highlight-end -+ -+// highlight-start -+const NewTaskForm = () => { -+ const handleSubmit = async (event: FormEvent) => { -+ event.preventDefault() -+ try { -+ const target = event.target as HTMLFormElement -+ const description = target.description.value -+ target.reset() -+ await createTask({ description }) -+ } catch (err: any) { -+ window.alert('Error: ' + err.message) -+ } -+ } -+ -+ return ( -+
-+ -+ -+
-+ ) -+} -+// highlight-end - -``` - -
+ ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from 'react' @@ -190,25 +118,7 @@ Unlike Queries, you can call Actions directly (without wrapping them in a hook) All that's left now is adding this form to the page component: - - -```diff -diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 50118d6..296d0cf 100644 ---- a/src/MainPage.tsx -+++ b/src/MainPage.tsx -@@ -13,6 +13,7 @@ export const MainPage = () => { - - return ( -
-+ - {tasks && } - - {isLoading && 'Loading...'} - -``` - - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from 'react' @@ -265,26 +175,7 @@ Since we've already created one task together, try to create this one yourself. Declaring the Action in `main.wasp`: - - - ```diff - diff --git a/main.wasp b/main.wasp - index e83fe65..31483d2 100644 - --- a/main.wasp - +++ b/main.wasp - @@ -24,3 +24,8 @@ action createTask { - fn: import { createTask } from "@src/actions", - entities: [Task] - } - + - +action updateTask { - + fn: import { updateTask } from "@src/actions", - + entities: [Task] - +} - - ``` - - + ```wasp title="main.wasp" // ... @@ -295,45 +186,7 @@ Since we've already created one task together, try to create this one yourself. } ``` - - - ```diff - diff --git a/src/actions.ts b/src/actions.ts - index 3edb8fb..45c82eb 100644 - --- a/src/actions.ts - +++ b/src/actions.ts - @@ -1,5 +1,5 @@ - import { Task } from 'wasp/entities' - -import { CreateTask } from 'wasp/server/operations' - +import { CreateTask, UpdateTask } from 'wasp/server/operations' - - type CreateTaskPayload = Pick - - @@ -10,4 +10,18 @@ export const createTask: CreateTask = async ( - return context.entities.Task.create({ - data: { description: args.description }, - }) - -} - \ No newline at end of file - +} - + - +type UpdateTaskPayload = Pick - + - +export const updateTask: UpdateTask = async ( - + { id, isDone }, - + context - +) => { - + return context.entities.Task.update({ - + where: { id }, - + data: { - + isDone: isDone, - + }, - + }) - +} - - ``` - - + Implementing the Action on the server: @@ -360,57 +213,7 @@ Since we've already created one task together, try to create this one yourself. You can now call `updateTask` from the React component: - - -```diff -diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 296d0cf..f02e05e 100644 ---- a/src/MainPage.tsx -+++ b/src/MainPage.tsx -@@ -1,7 +1,8 @@ --import type { FormEvent } from 'react' -+import type { FormEvent, ChangeEvent } from 'react' - import type { Task } from 'wasp/entities' - import { - // highlight-next-line -+ updateTask, - createTask, - getTasks, - useQuery, -@@ -25,9 +26,28 @@ export const MainPage = () => { - - // highlight-start - const TaskView = ({ task }: { task: Task }) => { -+ // highlight-start -+ const handleIsDoneChange = async (event: ChangeEvent) => { -+ try { -+ await updateTask({ -+ id: task.id, -+ isDone: event.target.checked, -+ }) -+ } catch (error: any) { -+ window.alert('Error while updating task: ' + error.message) -+ } -+ } -+ // highlight-end -+ - return ( -
-- -+ - {task.description} -
- ) - -``` - -
+ ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent, ChangeEvent } from 'react' diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index f4531bfecd..90d8967965 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -24,28 +24,7 @@ Since Wasp manages authentication, it will create [the auth related entities](.. You must only add the `User` Entity to keep track of who owns which tasks: - - -```diff -diff --git a/schema.prisma b/schema.prisma -index 65d7d30..e07f47c 100644 ---- a/schema.prisma -+++ b/schema.prisma -@@ -9,6 +9,10 @@ generator client { - provider = "prisma-client-js" - } - -+model User { -+ id Int @id @default(autoincrement()) -+} -+ - model Task { - id Int @id @default(autoincrement()) - description String - -``` - - + ```prisma title="schema.prisma" // ... @@ -59,36 +38,7 @@ model User { Next, tell Wasp to use full-stack [authentication](../auth/overview): - - -```diff -diff --git a/main.wasp b/main.wasp -index 42f8f6e..9b61df0 100644 ---- a/main.wasp -+++ b/main.wasp -@@ -2,7 +2,17 @@ app TodoApp { - wasp: { - version: "^0.16.3" - }, -- title: "TodoApp" -+ title: "TodoApp", -+ auth: { -+ // Tells Wasp which entity to use for storing users. -+ userEntity: User, -+ methods: { -+ // Enable username and password auth. -+ usernameAndPassword: {} -+ }, -+ // We'll see how this is used in a bit. -+ onAuthFailedRedirectTo: "/login" -+ } - } - - route RootRoute { path: "/", to: MainPage } - -``` - - + ```wasp title="main.wasp" app TodoApp { @@ -136,31 +86,7 @@ Wasp also supports authentication using [Google](../auth/social-auth/google), [G Wasp creates the login and signup forms for us, but we still need to define the pages to display those forms on. We'll start by declaring the pages in the Wasp file: - - -```diff -diff --git a/main.wasp b/main.wasp -index 4dccb7d..12a0895 100644 ---- a/main.wasp -+++ b/main.wasp -@@ -39,3 +39,13 @@ action updateTask { - fn: import { updateTask } from "@src/actions", - entities: [Task] - } -+ -+route SignupRoute { path: "/signup", to: SignupPage } -+page SignupPage { -+ component: import { SignupPage } from "@src/SignupPage" -+} -+ -+route LoginRoute { path: "/login", to: LoginPage } -+page LoginPage { -+ component: import { LoginPage } from "@src/LoginPage" -+} - -``` - - + ```wasp title="main.wasp" // ... @@ -234,25 +160,7 @@ export const SignupPage = () => { We don't want users who are not logged in to access the main page, because they won't be able to create any tasks. So let's make the page private by requiring the user to be logged in: - - -```diff -diff --git a/main.wasp b/main.wasp -index 12a0895..c621b88 100644 ---- a/main.wasp -+++ b/main.wasp -@@ -17,6 +17,7 @@ app TodoApp { - - route RootRoute { path: "/", to: MainPage } - page MainPage { -+ authRequired: true, - component: import { MainPage } from "@src/MainPage" - } - - -``` - - + ```wasp title="main.wasp" // ... @@ -268,28 +176,7 @@ Now that auth is required for this page, unauthenticated users will be redirecte Additionally, when `authRequired` is `true`, the page's React component will be provided a `user` object as prop. - - -```diff -diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index f02e05e..a4389aa 100644 ---- a/src/MainPage.tsx -+++ b/src/MainPage.tsx -@@ -7,8 +7,9 @@ import { - getTasks, - useQuery, - } from 'wasp/client/operations' -+import { AuthUser } from 'wasp/auth' - --export const MainPage = () => { -+export const MainPage = ({ user }: { user: AuthUser }) => { - // highlight-start - const { data: tasks, isLoading, error } = useQuery(getTasks) - - -``` - - + ```tsx title="src/MainPage.tsx" auto-js import type { AuthUser } from 'wasp/auth' @@ -325,33 +212,7 @@ However, you will notice that if you try logging in as different users and creat First, let's define a one-to-many relation between users and tasks (check the [Prisma docs on relations](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/relations)): - - -```diff -diff --git a/schema.prisma b/schema.prisma -index e07f47c..aad0a3c 100644 ---- a/schema.prisma -+++ b/schema.prisma -@@ -10,11 +10,14 @@ generator client { - } - - model User { -- id Int @id @default(autoincrement()) -+ id Int @id @default(autoincrement()) -+ tasks Task[] - } - - model Task { - id Int @id @default(autoincrement()) - description String - isDone Boolean @default(false) -+ user User? @relation(fields: [userId], references: [id]) -+ userId Int? - } - -``` - - + ```prisma title="schema.prisma" // ... @@ -392,34 +253,7 @@ Instead, we would do a data migration to take care of those tasks, even if it me Next, let's update the queries and actions to forbid access to non-authenticated users and to operate only on the currently logged-in user's tasks: - - -```diff -diff --git a/src/queries.ts b/src/queries.ts -index 49d17ec..dc744eb 100644 ---- a/src/queries.ts -+++ b/src/queries.ts -@@ -1,8 +1,13 @@ - import { Task } from 'wasp/entities' --import { type GetTasks } from 'wasp/server/operations' -+import { HttpError } from 'wasp/server' -+import { GetTasks } from 'wasp/server/operations' - - export const getTasks: GetTasks = async (args, context) => { -+ if (!context.user) { -+ throw new HttpError(401) -+ } - return context.entities.Task.findMany({ -+ where: { user: { id: context.user.id } }, - orderBy: { id: 'asc' }, - }) --} -\ No newline at end of file -+} - -``` - - + ```ts title="src/queries.ts" auto-js import type { Task } from 'wasp/entities' @@ -441,62 +275,7 @@ export const getTasks: GetTasks = async (args, context) => { } ``` - - -```diff -diff --git a/src/actions.ts b/src/actions.ts -index 45c82eb..1a0d088 100644 ---- a/src/actions.ts -+++ b/src/actions.ts -@@ -1,4 +1,5 @@ - import { Task } from 'wasp/entities' -+import { HttpError } from 'wasp/server' - import { CreateTask, UpdateTask } from 'wasp/server/operations' - - type CreateTaskPayload = Pick -@@ -7,21 +8,28 @@ export const createTask: CreateTask = async ( - args, - context - ) => { -+ if (!context.user) { -+ throw new HttpError(401) -+ } - return context.entities.Task.create({ -- data: { description: args.description }, -+ data: { -+ description: args.description, -+ user: { connect: { id: context.user.id } }, -+ }, - }) - } - - type UpdateTaskPayload = Pick - --export const updateTask: UpdateTask = async ( -- { id, isDone }, -- context --) => { -- return context.entities.Task.update({ -- where: { id }, -- data: { -- isDone: isDone, -- }, -+export const updateTask: UpdateTask< -+ UpdateTaskPayload, -+ { count: number } -+> = async ({ id, isDone }, context) => { -+ if (!context.user) { -+ throw new HttpError(401) -+ } -+ return context.entities.Task.updateMany({ -+ where: { id, user: { id: context.user.id } }, -+ data: { isDone }, - }) - } - -``` - - + ```ts title="src/actions.ts" auto-js import type { Task } from 'wasp/entities' @@ -562,35 +341,7 @@ You will see that each user has their tasks, just as we specified in our code! Last, but not least, let's add the logout functionality: - - -```diff -diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index a4389aa..b1f94f5 100644 ---- a/src/MainPage.tsx -+++ b/src/MainPage.tsx -@@ -7,7 +7,8 @@ import { - getTasks, - useQuery, - } from 'wasp/client/operations' --+import { AuthUser } from 'wasp/auth' -+import { AuthUser } from 'wasp/auth' -+import { logout } from 'wasp/client/auth' - - export const MainPage = ({ user }: { user: AuthUser }) => { - // highlight-start -@@ -20,6 +21,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { - - {isLoading && 'Loading...'} - {error && 'Error: ' + error} -+ -
- ) - // highlight-end - -``` - -
+ ```tsx title="src/MainPage.tsx" auto-js with-hole // ... diff --git a/web/docs/tutorial/patches/step-12.patch b/web/docs/tutorial/patches/step-12.patch index 3ffa450a8d..0d0ba4d16f 100644 --- a/web/docs/tutorial/patches/step-12.patch +++ b/web/docs/tutorial/patches/step-12.patch @@ -3,9 +3,9 @@ index e83fe65..31483d2 100644 --- a/main.wasp +++ b/main.wasp @@ -24,3 +24,8 @@ action createTask { - fn: import { createTask } from "@src/actions", - entities: [Task] -} + fn: import { createTask } from "@src/actions", + entities: [Task] + } + +action updateTask { + fn: import { updateTask } from "@src/actions", diff --git a/web/docs/tutorial/patches/step-13.patch b/web/docs/tutorial/patches/step-13.patch index 5d864a5fc1..7d48ac8ed2 100644 --- a/web/docs/tutorial/patches/step-13.patch +++ b/web/docs/tutorial/patches/step-13.patch @@ -1,11 +1,11 @@ diff --git a/src/actions.ts b/src/actions.ts -index 3edb8fb..45c82eb 100644 +index a7c1b28..d8372b9 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,5 +1,5 @@ - import { Task } from 'wasp/entities' --import { CreateTask } from 'wasp/server/operations' -+import { CreateTask, UpdateTask } from 'wasp/server/operations' + import type { Task } from 'wasp/entities' +-import type { CreateTask } from 'wasp/server/operations' ++import type { CreateTask, UpdateTask } from 'wasp/server/operations' type CreateTaskPayload = Pick diff --git a/web/docs/tutorial/patches/step-16.patch b/web/docs/tutorial/patches/step-16.patch index f529ece110..cea17b8658 100644 --- a/web/docs/tutorial/patches/step-16.patch +++ b/web/docs/tutorial/patches/step-16.patch @@ -1,10 +1,10 @@ diff --git a/main.wasp b/main.wasp -index 42f8f6e..9b61df0 100644 +index 31483d2..3a70018 100644 --- a/main.wasp +++ b/main.wasp @@ -2,7 +2,17 @@ app TodoApp { wasp: { - version: "^0.16.3" + version: "^0.16.0" }, - title: "TodoApp" + title: "TodoApp", diff --git a/web/docs/tutorial/patches/step-18.patch b/web/docs/tutorial/patches/step-18.patch index e4b287df79..22930da750 100644 --- a/web/docs/tutorial/patches/step-18.patch +++ b/web/docs/tutorial/patches/step-18.patch @@ -1,11 +1,11 @@ diff --git a/main.wasp b/main.wasp -index 4dccb7d..12a0895 100644 +index 3a70018..5e921a0 100644 --- a/main.wasp +++ b/main.wasp @@ -39,3 +39,13 @@ action updateTask { - fn: import { updateTask } from "@src/actions", - entities: [Task] - } + fn: import { updateTask } from "@src/actions", + entities: [Task] + } + +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { diff --git a/web/docs/tutorial/patches/step-21.patch b/web/docs/tutorial/patches/step-21.patch index 3bc5532055..1d25d45af9 100644 --- a/web/docs/tutorial/patches/step-21.patch +++ b/web/docs/tutorial/patches/step-21.patch @@ -1,5 +1,5 @@ diff --git a/main.wasp b/main.wasp -index 12a0895..c621b88 100644 +index 5e921a0..4f51c6f 100644 --- a/main.wasp +++ b/main.wasp @@ -17,6 +17,7 @@ app TodoApp { @@ -9,4 +9,4 @@ index 12a0895..c621b88 100644 + authRequired: true, component: import { MainPage } from "@src/MainPage" } - + diff --git a/web/docs/tutorial/patches/step-25.patch b/web/docs/tutorial/patches/step-25.patch index 832d1859c2..1dbba8a062 100644 --- a/web/docs/tutorial/patches/step-25.patch +++ b/web/docs/tutorial/patches/step-25.patch @@ -1,21 +1,20 @@ diff --git a/src/queries.ts b/src/queries.ts -index 49d17ec..dc744eb 100644 +index 1738f22..f2ab046 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -1,8 +1,13 @@ - import { Task } from 'wasp/entities' --import { type GetTasks } from 'wasp/server/operations' + import type { Task } from 'wasp/entities' +import { HttpError } from 'wasp/server' -+import { GetTasks } from 'wasp/server/operations' - - export const getTasks: GetTasks = async (args, context) => { + import type { GetTasks } from 'wasp/server/operations' + + export const getTasks: GetTasks = async (args, context) => { + if (!context.user) { + throw new HttpError(401) + } - return context.entities.Task.findMany({ + return context.entities.Task.findMany({ + where: { user: { id: context.user.id } }, - orderBy: { id: 'asc' }, - }) + orderBy: { id: 'asc' }, + }) -} \ No newline at end of file +} diff --git a/web/docs/tutorial/patches/step-26.patch b/web/docs/tutorial/patches/step-26.patch index e25c8ae00f..b8e611399f 100644 --- a/web/docs/tutorial/patches/step-26.patch +++ b/web/docs/tutorial/patches/step-26.patch @@ -1,31 +1,35 @@ diff --git a/src/actions.ts b/src/actions.ts -index 45c82eb..1a0d088 100644 +index d8372b9..b796bd2 100644 --- a/src/actions.ts +++ b/src/actions.ts -@@ -1,4 +1,5 @@ - import { Task } from 'wasp/entities' +@@ -1,4 +1,6 @@ + import type { Task } from 'wasp/entities' ++// highlight-next-line +import { HttpError } from 'wasp/server' - import { CreateTask, UpdateTask } from 'wasp/server/operations' - - type CreateTaskPayload = Pick -@@ -7,21 +8,28 @@ export const createTask: CreateTask = async ( - args, - context - ) => { + import type { CreateTask, UpdateTask } from 'wasp/server/operations' + + type CreateTaskPayload = Pick +@@ -7,21 +9,33 @@ export const createTask: CreateTask = async ( + args, + context + ) => { ++ // highlight-start + if (!context.user) { + throw new HttpError(401) + } - return context.entities.Task.create({ ++ // highlight-end + return context.entities.Task.create({ - data: { description: args.description }, + data: { + description: args.description, ++ // highlight-next-line + user: { connect: { id: context.user.id } }, + }, - }) - } - - type UpdateTaskPayload = Pick - + }) + } + + type UpdateTaskPayload = Pick + -export const updateTask: UpdateTask = async ( - { id, isDone }, - context @@ -38,12 +42,14 @@ index 45c82eb..1a0d088 100644 +export const updateTask: UpdateTask< + UpdateTaskPayload, + { count: number } -+> = async ({ id, isDone }, context) => { ++> = async (args, context) => { ++ // highlight-start + if (!context.user) { + throw new HttpError(401) + } ++ // highlight-end + return context.entities.Task.updateMany({ -+ where: { id, user: { id: context.user.id } }, -+ data: { isDone }, - }) - } ++ where: { id: args.id, user: { id: context.user.id } }, ++ data: { isDone: args.isDone }, + }) + } diff --git a/web/docs/tutorial/patches/step-27.patch b/web/docs/tutorial/patches/step-27.patch index 93f26deb7f..f320e30bef 100644 --- a/web/docs/tutorial/patches/step-27.patch +++ b/web/docs/tutorial/patches/step-27.patch @@ -1,13 +1,11 @@ diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index a4389aa..b1f94f5 100644 +index 6075b9b..b1f94f5 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx -@@ -7,7 +7,8 @@ import { - getTasks, +@@ -8,6 +8,7 @@ import { useQuery, } from 'wasp/client/operations' --+import { AuthUser } from 'wasp/auth' -+import { AuthUser } from 'wasp/auth' + import { AuthUser } from 'wasp/auth' +import { logout } from 'wasp/client/auth' export const MainPage = ({ user }: { user: AuthUser }) => { diff --git a/web/tutorial/src/actions/diff.ts b/web/tutorial/src/actions/diff.ts index 7f3dee7126..fa6e01eb9f 100644 --- a/web/tutorial/src/actions/diff.ts +++ b/web/tutorial/src/actions/diff.ts @@ -1,10 +1,21 @@ -import type { ActionCommon, ApplyPatchAction } from './index' +import path from 'path' +import fs from 'fs' + import parseGitDiff from 'parse-git-diff' +import type { ActionCommon, ApplyPatchAction } from './index' + export function createApplyPatchAction( - patch: string, commonActionData: ActionCommon ): ApplyPatchAction { + const patchContentPath = path.resolve( + '../docs/tutorial', + 'patches', + `step-${commonActionData.step}.patch` + ) + + const patch = fs.readFileSync(patchContentPath, 'utf-8') + const parsedPatch = parseGitDiff(patch) if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { @@ -19,12 +30,12 @@ export function createApplyPatchAction( throw new Error('Invalid patch: only file changes are supported') } - const path = parsedPatch.files[0].path + const targetFilePath = parsedPatch.files[0].path return { ...commonActionData, kind: 'diff', - patch, - path, + targetFilePath, + patchContentPath, } } diff --git a/web/tutorial/src/actions/index.ts b/web/tutorial/src/actions/index.ts index 439dc0ff1d..2571749e5e 100644 --- a/web/tutorial/src/actions/index.ts +++ b/web/tutorial/src/actions/index.ts @@ -20,8 +20,8 @@ export type WriteFileAction = { export type ApplyPatchAction = { kind: 'diff' - patch: string - path: string + targetFilePath: string + patchContentPath: string } & ActionCommon export type MigrateDbAction = { @@ -37,10 +37,12 @@ export async function writeFileToAppDir(file: WriteFileAction) { } export async function applyPatch(patch: ApplyPatchAction) { - const patchPath = path.resolve(patchesDir, `step-${patch.step}.patch`) - await fs.writeFile(patchPath, patch.patch) - await $`cd ${appDir} && git apply ${patchPath} --verbose`.quiet(true) - log('info', `Applied patch to ${patch.path}`) + // const patchPath = path.resolve(patchesDir, `step-${patch.step}.patch`) + // await fs.writeFile(patchPath, patch.patch) + await $`cd ${appDir} && git apply ${patch.patchContentPath} --verbose`.quiet( + true + ) + log('info', `Applied patch to ${patch.targetFilePath}`) } export const migrateDb = waspDbMigrate diff --git a/web/tutorial/src/edit/generate-patch.ts b/web/tutorial/src/edit/generate-patch.ts index 3a89daa29c..d0937281f8 100644 --- a/web/tutorial/src/edit/generate-patch.ts +++ b/web/tutorial/src/edit/generate-patch.ts @@ -2,10 +2,12 @@ import { $ } from 'zx' import { appDir } from '../paths' export async function makeCheckpoint(): Promise { - await $`cd ${appDir} && git add . && git commit -m "checkpoint"` + await $`cd ${appDir} && git add . && git commit -m "checkpoint"`.verbose( + false + ) } export async function generateGitPatch(): Promise { - const { stdout: patch } = await $`cd ${appDir} && git diff` + const { stdout: patch } = await $`cd ${appDir} && git diff`.verbose(false) return patch } diff --git a/web/tutorial/src/edit/index.ts b/web/tutorial/src/edit/index.ts new file mode 100644 index 0000000000..9971e19620 --- /dev/null +++ b/web/tutorial/src/edit/index.ts @@ -0,0 +1,45 @@ +import fs from 'fs/promises' +import Enquirer from 'enquirer' + +import type { Action, ApplyPatchAction } from '../actions' +import { executeSteps } from '../execute-steps' +import { generateGitPatch, makeCheckpoint } from './generate-patch' +import { log } from '../log' + +export async function updateBrokenDiffs( + editDiff: ApplyPatchAction, + actions: Action[] +) { + const actionsBeforeStep = actions.filter( + (action) => action.step < editDiff.step + ) + await executeSteps(actionsBeforeStep, { + untilStep: editDiff.step, + }) + const actionsToEdit = actions + .filter((action) => action.kind === 'diff') + .filter( + (action) => + action.targetFilePath === editDiff.targetFilePath && + action.step >= editDiff.step + ) + + log( + 'info', + `We are now going to edit all the steps for file ${editDiff.targetFilePath} from step ${editDiff.step} onwards` + ) + + for (const action of actionsToEdit) { + const { step, targetFilePath, patchContentPath } = action + await makeCheckpoint() + await Enquirer.prompt({ + type: 'confirm', + name: 'edit', + message: `Apply the new edit to ${targetFilePath} at step ${step} and press Enter`, + initial: true, + }) + const patch = await generateGitPatch() + await fs.writeFile(patchContentPath, patch, 'utf-8') + log('info', `Patch for step ${step} written to ${patchContentPath}.`) + } +} diff --git a/web/tutorial/src/execute-steps/index.ts b/web/tutorial/src/execute-steps/index.ts index cd0df187ae..52147e847f 100644 --- a/web/tutorial/src/execute-steps/index.ts +++ b/web/tutorial/src/execute-steps/index.ts @@ -31,8 +31,6 @@ export async function executeSteps( try { switch (kind) { case 'diff': - // TODO: Implement edit mode which would make it easier to edit diffs - await applyPatch(action) break case 'write': @@ -48,6 +46,5 @@ export async function executeSteps( log('error', `Error in step ${action.step}:\n\n${err}`) process.exit(1) } - log('info', 'All done!') } } diff --git a/web/tutorial/src/index.ts b/web/tutorial/src/index.ts index a926e516de..6eca87c371 100644 --- a/web/tutorial/src/index.ts +++ b/web/tutorial/src/index.ts @@ -1,29 +1,24 @@ import path from 'path' -import { $, chalk } from 'zx' +import { $ } from 'zx' import fs from 'fs/promises' -import { - applyPatch, - migrateDb, - writeFileToAppDir, - type Action, -} from './actions/index' -import { appDir, ensureDirExists, patchesDir } from './paths' +import type { Action } from './actions/index' +import { appDir } from './paths' import { waspNew } from './waspCli' -import { log } from './log' import { getActionsFromTutorialFiles } from './markdown/extractSteps' import { program } from '@commander-js/extra-typings' import Enquirer from 'enquirer' import { generateGitPatch, makeCheckpoint } from './edit/generate-patch' import { executeSteps } from './execute-steps' -import { write } from 'fs' +import { log } from './log' +import { updateBrokenDiffs } from './edit' const actions: Action[] = await getActionsFromTutorialFiles() -const { editDiff, untilStep } = program +const { brokenDiff, untilStep } = program .option( '-s, --until-step ', 'Run until the given step. If not provided, run all steps.', @@ -36,7 +31,7 @@ const { editDiff, untilStep } = program } ) .option( - '-e, --edit-diff ', + '-e, --broken-diff ', 'Edit mode, you will edit the diff interactively and all the steps related to the same file that come after.', (value: string) => { const step = parseInt(value, 10) @@ -71,39 +66,8 @@ async function prepareApp() { await prepareApp() -if (editDiff) { - const actionsBeforeStep = actions.filter( - (action) => action.step < editDiff.step - ) - await executeSteps(actionsBeforeStep, { - untilStep: editDiff.step, - }) - const actionsToEdit = actions - .filter((action) => action.kind === 'diff') - .filter( - (action) => action.path === editDiff.path && action.step >= editDiff.step - ) - - // Okay, we are going to edit now all the steps that are related to the same file - // starting with step editDiff.step - console.log( - `We are now going to edit all the steps for file ${editDiff.path} from step ${editDiff.step} onwards` - ) - - for (const action of actionsToEdit) { - const { step, path } = action - await makeCheckpoint() - await Enquirer.prompt({ - type: 'confirm', - name: 'edit', - message: `Apply the new edit to ${path} at step ${step} and press Enter`, - initial: true, - }) - const patch = await generateGitPatch() - console.log('=====================') - console.log(patch) - console.log('=====================') - } +if (brokenDiff) { + await updateBrokenDiffs(brokenDiff, actions) } else { await executeSteps(actions, { untilStep, diff --git a/web/tutorial/src/markdown/extractSteps.ts b/web/tutorial/src/markdown/extractSteps.ts index 18d19804fe..d7755e6ac2 100644 --- a/web/tutorial/src/markdown/extractSteps.ts +++ b/web/tutorial/src/markdown/extractSteps.ts @@ -76,6 +76,12 @@ async function getActionsFromFile(filePath: string): Promise { return } + if (action === 'diff') { + const patchAction = createApplyPatchAction(commonActionData) + actions.push(patchAction) + return + } + if (node.children.length !== 1) { throw new Error(`${componentName} must have exactly one child`) } @@ -87,9 +93,7 @@ async function getActionsFromFile(filePath: string): Promise { const codeBlockCode = childCode.value - if (action === 'diff') { - actions.push(createApplyPatchAction(codeBlockCode, commonActionData)) - } else if (action === 'write') { + if (action === 'write') { const path = getAttributeValue(node, 'path') if (!path) { throw new Error('Path attribute is required for write action') From 8cf25a8deaa145a4d8468ed7ac24d7645a9d4171 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 19 May 2025 19:28:55 +0200 Subject: [PATCH 06/48] Cleanup --- web/tutorial/src/edit/index.ts | 4 ++-- web/tutorial/src/index.ts | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/web/tutorial/src/edit/index.ts b/web/tutorial/src/edit/index.ts index 9971e19620..88e83cf637 100644 --- a/web/tutorial/src/edit/index.ts +++ b/web/tutorial/src/edit/index.ts @@ -16,7 +16,7 @@ export async function updateBrokenDiffs( await executeSteps(actionsBeforeStep, { untilStep: editDiff.step, }) - const actionsToEdit = actions + const diffActionsForSameFile = actions .filter((action) => action.kind === 'diff') .filter( (action) => @@ -29,7 +29,7 @@ export async function updateBrokenDiffs( `We are now going to edit all the steps for file ${editDiff.targetFilePath} from step ${editDiff.step} onwards` ) - for (const action of actionsToEdit) { + for (const action of diffActionsForSameFile) { const { step, targetFilePath, patchContentPath } = action await makeCheckpoint() await Enquirer.prompt({ diff --git a/web/tutorial/src/index.ts b/web/tutorial/src/index.ts index 6eca87c371..174415f0df 100644 --- a/web/tutorial/src/index.ts +++ b/web/tutorial/src/index.ts @@ -2,18 +2,13 @@ import path from 'path' import { $ } from 'zx' -import fs from 'fs/promises' - import type { Action } from './actions/index' import { appDir } from './paths' import { waspNew } from './waspCli' import { getActionsFromTutorialFiles } from './markdown/extractSteps' import { program } from '@commander-js/extra-typings' -import Enquirer from 'enquirer' -import { generateGitPatch, makeCheckpoint } from './edit/generate-patch' import { executeSteps } from './execute-steps' -import { log } from './log' import { updateBrokenDiffs } from './edit' const actions: Action[] = await getActionsFromTutorialFiles() From 72c0964bb942125e12d9d3974617e71da66f07ef Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 16 Jul 2025 13:27:39 +0200 Subject: [PATCH 07/48] Cleanup --- web/scripts/generate-llm-files.mts | 0 web/src/components/TutorialAction.tsx | 10 +- .../.gitignore | 2 +- web/tutorial-app-generator/README.md | 34 ++ .../package-lock.json | 300 ++++++++++-------- .../package.json | 17 +- .../src/actions/diff.ts | 41 +++ .../src/actions/index.ts | 49 +++ .../src/edit/generate-patch.ts | 12 + web/tutorial-app-generator/src/edit/index.ts | 46 +++ .../src/execute-steps/index.ts | 45 +++ web/tutorial-app-generator/src/index.ts | 70 ++++ .../src/log.ts | 8 +- .../src/markdown/customHeadingId.ts | 12 +- .../src/markdown/extractSteps.ts | 128 ++++++++ web/tutorial-app-generator/src/paths.ts | 8 + web/tutorial-app-generator/src/waspCli.ts | 15 + .../tsconfig.json | 0 web/tutorial/src/actions/diff.ts | 41 --- web/tutorial/src/actions/index.ts | 50 --- web/tutorial/src/edit/generate-patch.ts | 13 - web/tutorial/src/edit/index.ts | 45 --- web/tutorial/src/execute-steps/index.ts | 50 --- web/tutorial/src/index.ts | 70 ---- web/tutorial/src/markdown/extractSteps.ts | 128 -------- web/tutorial/src/paths.ts | 8 - web/tutorial/src/waspCli.ts | 14 - 27 files changed, 632 insertions(+), 584 deletions(-) create mode 100644 web/scripts/generate-llm-files.mts rename web/{tutorial => tutorial-app-generator}/.gitignore (71%) create mode 100644 web/tutorial-app-generator/README.md rename web/{tutorial => tutorial-app-generator}/package-lock.json (87%) rename web/{tutorial => tutorial-app-generator}/package.json (68%) create mode 100644 web/tutorial-app-generator/src/actions/diff.ts create mode 100644 web/tutorial-app-generator/src/actions/index.ts create mode 100644 web/tutorial-app-generator/src/edit/generate-patch.ts create mode 100644 web/tutorial-app-generator/src/edit/index.ts create mode 100644 web/tutorial-app-generator/src/execute-steps/index.ts create mode 100644 web/tutorial-app-generator/src/index.ts rename web/{tutorial => tutorial-app-generator}/src/log.ts (51%) rename web/{tutorial => tutorial-app-generator}/src/markdown/customHeadingId.ts (55%) create mode 100644 web/tutorial-app-generator/src/markdown/extractSteps.ts create mode 100644 web/tutorial-app-generator/src/paths.ts create mode 100644 web/tutorial-app-generator/src/waspCli.ts rename web/{tutorial => tutorial-app-generator}/tsconfig.json (100%) delete mode 100644 web/tutorial/src/actions/diff.ts delete mode 100644 web/tutorial/src/actions/index.ts delete mode 100644 web/tutorial/src/edit/generate-patch.ts delete mode 100644 web/tutorial/src/edit/index.ts delete mode 100644 web/tutorial/src/execute-steps/index.ts delete mode 100644 web/tutorial/src/index.ts delete mode 100644 web/tutorial/src/markdown/extractSteps.ts delete mode 100644 web/tutorial/src/paths.ts delete mode 100644 web/tutorial/src/waspCli.ts diff --git a/web/scripts/generate-llm-files.mts b/web/scripts/generate-llm-files.mts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/components/TutorialAction.tsx b/web/src/components/TutorialAction.tsx index e19e5ffaa4..eb1a3b95d1 100644 --- a/web/src/components/TutorialAction.tsx +++ b/web/src/components/TutorialAction.tsx @@ -1,13 +1,13 @@ -import React from 'react' +import React from "react"; export function TutorialAction({ children, action, }: React.PropsWithChildren<{ - action: 'diff' | 'write' | 'migrate-db' + action: "diff" | "write" | "migrate-db"; }>) { - if (action === 'write') { - return children + if (action === "write") { + return children; } - return null + return null; } diff --git a/web/tutorial/.gitignore b/web/tutorial-app-generator/.gitignore similarity index 71% rename from web/tutorial/.gitignore rename to web/tutorial-app-generator/.gitignore index a8aac67f63..5d545e6467 100644 --- a/web/tutorial/.gitignore +++ b/web/tutorial-app-generator/.gitignore @@ -1,3 +1,3 @@ node_modules/ TodoApp/ -patches/ \ No newline at end of file +patches/ diff --git a/web/tutorial-app-generator/README.md b/web/tutorial-app-generator/README.md new file mode 100644 index 0000000000..a22af5e554 --- /dev/null +++ b/web/tutorial-app-generator/README.md @@ -0,0 +1,34 @@ +# Tutorial App Generator + +Generates a Wasp app by executing tutorial steps from markdown files. + +## Usage + +```bash +npm run start +``` + +## Options + +- `-s, --until-step ` - Run until the given step number +- `-e, --broken-diff ` - Edit mode for fixing diffs interactively + +## Examples + +```bash +# Run all steps +npm run start + +# Run until step 5 +npm run start -- -s 5 + +# Edit diff at step 3 +npm run start -- -e 3 +``` + +## What it does + +1. Creates a new Wasp app in the output directory +2. Parses tutorial markdown files to extract steps +3. Executes diff actions and other steps sequentially +4. Supports interactive editing of broken diffs diff --git a/web/tutorial/package-lock.json b/web/tutorial-app-generator/package-lock.json similarity index 87% rename from web/tutorial/package-lock.json rename to web/tutorial-app-generator/package-lock.json index e970a77e25..172ba025ed 100644 --- a/web/tutorial/package-lock.json +++ b/web/tutorial-app-generator/package-lock.json @@ -1,17 +1,17 @@ { - "name": "tutorial", + "name": "tutorial-app-generator", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tutorial", + "name": "tutorial-app-generator", "version": "0.0.1", "license": "MIT", "dependencies": { - "@commander-js/extra-typings": "^13.1.0", - "acorn": "^8.14.1", - "commander": "^13.1.0", + "@commander-js/extra-typings": "^14.0.0", + "acorn": "8.15.0", + "commander": "^14.0.0", "dedent": "^1.6.0", "enquirer": "^2.4.1", "mdast-util-from-markdown": "^2.0.2", @@ -20,7 +20,7 @@ "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-markdown": "^2.1.2", "micromark-extension-mdx-jsx": "^3.0.2", - "parse-git-diff": "^0.0.17", + "parse-git-diff": "0.0.19", "remark-comment": "^1.0.0", "unist-util-visit": "^5.0.0", "zx": "^8.5.3" @@ -31,18 +31,18 @@ } }, "node_modules/@commander-js/extra-typings": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz", - "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", + "integrity": "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==", "license": "MIT", "peerDependencies": { - "commander": "~13.1.0" + "commander": "~14.0.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", - "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", + "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", "cpu": [ "ppc64" ], @@ -57,9 +57,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", - "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz", + "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", "cpu": [ "arm" ], @@ -74,9 +74,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", - "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz", + "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", "cpu": [ "arm64" ], @@ -91,9 +91,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", - "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz", + "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", "cpu": [ "x64" ], @@ -108,9 +108,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", - "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", + "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", "cpu": [ "arm64" ], @@ -125,9 +125,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", - "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz", + "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", "cpu": [ "x64" ], @@ -142,9 +142,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", - "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz", + "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", "cpu": [ "arm64" ], @@ -159,9 +159,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", - "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz", + "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", "cpu": [ "x64" ], @@ -176,9 +176,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", - "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz", + "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", "cpu": [ "arm" ], @@ -193,9 +193,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", - "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz", + "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", "cpu": [ "arm64" ], @@ -210,9 +210,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", - "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz", + "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", "cpu": [ "ia32" ], @@ -227,9 +227,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", - "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz", + "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", "cpu": [ "loong64" ], @@ -244,9 +244,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", - "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz", + "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", "cpu": [ "mips64el" ], @@ -261,9 +261,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", - "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz", + "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", "cpu": [ "ppc64" ], @@ -278,9 +278,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", - "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz", + "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", "cpu": [ "riscv64" ], @@ -295,9 +295,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", - "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz", + "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", "cpu": [ "s390x" ], @@ -312,9 +312,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", - "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz", + "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", "cpu": [ "x64" ], @@ -329,9 +329,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", - "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz", + "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", "cpu": [ "arm64" ], @@ -346,9 +346,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", - "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz", + "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", "cpu": [ "x64" ], @@ -363,9 +363,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", - "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz", + "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", "cpu": [ "arm64" ], @@ -380,9 +380,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", - "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz", + "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", "cpu": [ "x64" ], @@ -396,10 +396,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz", + "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", - "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz", + "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", "cpu": [ "x64" ], @@ -414,9 +431,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", - "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz", + "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", "cpu": [ "arm64" ], @@ -431,9 +448,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", - "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz", + "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", "cpu": [ "ia32" ], @@ -448,9 +465,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", - "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz", + "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", "cpu": [ "x64" ], @@ -474,9 +491,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -519,9 +536,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -599,18 +616,18 @@ } }, "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -625,9 +642,9 @@ } }, "node_modules/decode-named-character-reference": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", - "integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", "license": "MIT", "dependencies": { "character-entities": "^2.0.0" @@ -687,9 +704,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", - "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", + "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -700,31 +717,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.4", - "@esbuild/android-arm": "0.25.4", - "@esbuild/android-arm64": "0.25.4", - "@esbuild/android-x64": "0.25.4", - "@esbuild/darwin-arm64": "0.25.4", - "@esbuild/darwin-x64": "0.25.4", - "@esbuild/freebsd-arm64": "0.25.4", - "@esbuild/freebsd-x64": "0.25.4", - "@esbuild/linux-arm": "0.25.4", - "@esbuild/linux-arm64": "0.25.4", - "@esbuild/linux-ia32": "0.25.4", - "@esbuild/linux-loong64": "0.25.4", - "@esbuild/linux-mips64el": "0.25.4", - "@esbuild/linux-ppc64": "0.25.4", - "@esbuild/linux-riscv64": "0.25.4", - "@esbuild/linux-s390x": "0.25.4", - "@esbuild/linux-x64": "0.25.4", - "@esbuild/netbsd-arm64": "0.25.4", - "@esbuild/netbsd-x64": "0.25.4", - "@esbuild/openbsd-arm64": "0.25.4", - "@esbuild/openbsd-x64": "0.25.4", - "@esbuild/sunos-x64": "0.25.4", - "@esbuild/win32-arm64": "0.25.4", - "@esbuild/win32-ia32": "0.25.4", - "@esbuild/win32-x64": "0.25.4" + "@esbuild/aix-ppc64": "0.25.6", + "@esbuild/android-arm": "0.25.6", + "@esbuild/android-arm64": "0.25.6", + "@esbuild/android-x64": "0.25.6", + "@esbuild/darwin-arm64": "0.25.6", + "@esbuild/darwin-x64": "0.25.6", + "@esbuild/freebsd-arm64": "0.25.6", + "@esbuild/freebsd-x64": "0.25.6", + "@esbuild/linux-arm": "0.25.6", + "@esbuild/linux-arm64": "0.25.6", + "@esbuild/linux-ia32": "0.25.6", + "@esbuild/linux-loong64": "0.25.6", + "@esbuild/linux-mips64el": "0.25.6", + "@esbuild/linux-ppc64": "0.25.6", + "@esbuild/linux-riscv64": "0.25.6", + "@esbuild/linux-s390x": "0.25.6", + "@esbuild/linux-x64": "0.25.6", + "@esbuild/netbsd-arm64": "0.25.6", + "@esbuild/netbsd-x64": "0.25.6", + "@esbuild/openbsd-arm64": "0.25.6", + "@esbuild/openbsd-x64": "0.25.6", + "@esbuild/openharmony-arm64": "0.25.6", + "@esbuild/sunos-x64": "0.25.6", + "@esbuild/win32-arm64": "0.25.6", + "@esbuild/win32-ia32": "0.25.6", + "@esbuild/win32-x64": "0.25.6" } }, "node_modules/escape-string-regexp": { @@ -800,9 +818,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", - "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1597,9 +1615,9 @@ "license": "MIT" }, "node_modules/parse-git-diff": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/parse-git-diff/-/parse-git-diff-0.0.17.tgz", - "integrity": "sha512-Y9oguTLoWJOeGwOeaFP1SxaSIaIp3VtEm7NHHg8Dagaa4fQt2MwFHxdQBLk0LPseuFTgxOs9T+O+uS8Oe5oqEw==", + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/parse-git-diff/-/parse-git-diff-0.0.19.tgz", + "integrity": "sha512-oh3giwKzsPlOhekiDDyd/pfFKn04IZoTjEThquhfKigwiUHymiP/Tp6AN5nGIwXQdWuBTQvz9AaRdN5TBsJ8MA==", "license": "MIT" }, "node_modules/remark-comment": { @@ -1722,9 +1740,9 @@ } }, "node_modules/tsx": { - "version": "4.19.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", - "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1834,9 +1852,9 @@ } }, "node_modules/zx": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/zx/-/zx-8.5.3.tgz", - "integrity": "sha512-TsGLAt8Ngr4wDXLZmN9BT+6FWVLFbqdQ0qpXkV3tIfH7F+MgN/WUeSY7W4nNqAntjWunmnRaznpyxtJRPhCbUQ==", + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/zx/-/zx-8.7.1.tgz", + "integrity": "sha512-28u1w2LlIfvyvJvYe6pmCipesk8oL5AFMVp+P/U445LcaPgzrU5lNDtAPd6nJvWmoCNyXZz37R/xKOGokccjsw==", "license": "Apache-2.0", "bin": { "zx": "build/cli.js" diff --git a/web/tutorial/package.json b/web/tutorial-app-generator/package.json similarity index 68% rename from web/tutorial/package.json rename to web/tutorial-app-generator/package.json index 7eabeff548..8f285665d7 100644 --- a/web/tutorial/package.json +++ b/web/tutorial-app-generator/package.json @@ -1,17 +1,16 @@ { - "name": "tutorial", + "name": "tutorial-app-generator", "version": "0.0.1", - "main": "index.ts", "type": "module", + "license": "MIT", "scripts": { - "start": "tsx ./src/index.ts", - "parse-markdown": "tsx ./src/markdown/index.ts" + "parse-markdown": "tsx ./src/markdown/index.ts", + "start": "tsx ./src/index.ts" }, - "license": "MIT", "dependencies": { - "@commander-js/extra-typings": "^13.1.0", - "acorn": "^8.14.1", - "commander": "^13.1.0", + "@commander-js/extra-typings": "^14.0.0", + "acorn": "8.15.0", + "commander": "^14.0.0", "dedent": "^1.6.0", "enquirer": "^2.4.1", "mdast-util-from-markdown": "^2.0.2", @@ -20,7 +19,7 @@ "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-markdown": "^2.1.2", "micromark-extension-mdx-jsx": "^3.0.2", - "parse-git-diff": "^0.0.17", + "parse-git-diff": "0.0.19", "remark-comment": "^1.0.0", "unist-util-visit": "^5.0.0", "zx": "^8.5.3" diff --git a/web/tutorial-app-generator/src/actions/diff.ts b/web/tutorial-app-generator/src/actions/diff.ts new file mode 100644 index 0000000000..698e84a870 --- /dev/null +++ b/web/tutorial-app-generator/src/actions/diff.ts @@ -0,0 +1,41 @@ +import fs from "fs"; +import path from "path"; + +import parseGitDiff from "parse-git-diff"; + +import type { ActionCommon, ApplyPatchAction } from "./index"; + +export function createApplyPatchAction( + commonActionData: ActionCommon, +): ApplyPatchAction { + const patchContentPath = path.resolve( + "../docs/tutorial", + "patches", + `step-${commonActionData.step}.patch`, + ); + + const patch = fs.readFileSync(patchContentPath, "utf-8"); + + const parsedPatch = parseGitDiff(patch); + + if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { + throw new Error("Invalid patch: no changes found"); + } + + if (parsedPatch.files.length > 1) { + throw new Error("Invalid patch: multiple files changed"); + } + + if (parsedPatch.files[0].type !== "ChangedFile") { + throw new Error("Invalid patch: only file changes are supported"); + } + + const targetFilePath = parsedPatch.files[0].path; + + return { + ...commonActionData, + kind: "diff", + targetFilePath, + patchContentPath, + }; +} diff --git a/web/tutorial-app-generator/src/actions/index.ts b/web/tutorial-app-generator/src/actions/index.ts new file mode 100644 index 0000000000..0ec61a6477 --- /dev/null +++ b/web/tutorial-app-generator/src/actions/index.ts @@ -0,0 +1,49 @@ +import fs from "fs/promises"; +import path from "path"; + +import { $ } from "zx"; + +import { log } from "../log"; +import { waspDbMigrate } from "../waspCli"; + +export type ActionCommon = { + step: number; + markdownSourceFilePath: string; +}; + +export type WriteFileAction = { + kind: "write"; + path: string; + content: string; +} & ActionCommon; + +export type ApplyPatchAction = { + kind: "diff"; + targetFilePath: string; + patchContentPath: string; +} & ActionCommon; + +export type MigrateDbAction = { + kind: "migrate-db"; +} & ActionCommon; + +export type Action = WriteFileAction | ApplyPatchAction | MigrateDbAction; + +export async function writeFile(appDir: string, file: WriteFileAction) { + const filePath = path.resolve(appDir, file.path); + await fs.writeFile(filePath, file.content); + log("info", `Wrote to ${file.path}`); +} + +export async function applyPatch(appDir: string, patch: ApplyPatchAction) { + // const patchPath = path.resolve(patchesDir, `step-${patch.step}.patch`) + // await fs.writeFile(patchPath, patch.patch) + await $`cd ${appDir} && git apply ${patch.patchContentPath} --verbose`.quiet( + true, + ); + log("info", `Applied patch to ${patch.targetFilePath}`); +} + +export const migrateDb = waspDbMigrate; + +export { createApplyPatchAction } from "./diff"; diff --git a/web/tutorial-app-generator/src/edit/generate-patch.ts b/web/tutorial-app-generator/src/edit/generate-patch.ts new file mode 100644 index 0000000000..9a516d1ef1 --- /dev/null +++ b/web/tutorial-app-generator/src/edit/generate-patch.ts @@ -0,0 +1,12 @@ +import { $ } from "zx"; + +export async function makeCheckpoint(appDir: string): Promise { + await $`cd ${appDir} && git add . && git commit -m "checkpoint"`.verbose( + false, + ); +} + +export async function generateGitPatch(appDir: string): Promise { + const { stdout: patch } = await $`cd ${appDir} && git diff`.verbose(false); + return patch; +} diff --git a/web/tutorial-app-generator/src/edit/index.ts b/web/tutorial-app-generator/src/edit/index.ts new file mode 100644 index 0000000000..468ac7fbbb --- /dev/null +++ b/web/tutorial-app-generator/src/edit/index.ts @@ -0,0 +1,46 @@ +import Enquirer from "enquirer"; +import fs from "fs/promises"; + +import type { Action, ApplyPatchAction } from "../actions"; +import { executeSteps } from "../execute-steps"; +import { log } from "../log"; +import { appDir } from "../paths"; +import { generateGitPatch, makeCheckpoint } from "./generate-patch"; + +export async function updateBrokenDiffs( + editDiff: ApplyPatchAction, + actions: Action[], +) { + const actionsBeforeStep = actions.filter( + (action) => action.step < editDiff.step, + ); + await executeSteps(actionsBeforeStep, { + untilStep: editDiff.step, + }); + const diffActionsForSameFile = actions + .filter((action) => action.kind === "diff") + .filter( + (action) => + action.targetFilePath === editDiff.targetFilePath && + action.step >= editDiff.step, + ); + + log( + "info", + `We are now going to edit all the steps for file ${editDiff.targetFilePath} from step ${editDiff.step} onwards`, + ); + + for (const action of diffActionsForSameFile) { + const { step, targetFilePath, patchContentPath } = action; + await makeCheckpoint(appDir); + await Enquirer.prompt({ + type: "confirm", + name: "edit", + message: `Apply the new edit to ${targetFilePath} at step ${step} and press Enter`, + initial: true, + }); + const patch = await generateGitPatch(appDir); + await fs.writeFile(patchContentPath, patch, "utf-8"); + log("info", `Patch for step ${step} written to ${patchContentPath}.`); + } +} diff --git a/web/tutorial-app-generator/src/execute-steps/index.ts b/web/tutorial-app-generator/src/execute-steps/index.ts new file mode 100644 index 0000000000..aa77082f36 --- /dev/null +++ b/web/tutorial-app-generator/src/execute-steps/index.ts @@ -0,0 +1,45 @@ +import { chalk } from "zx"; +import { applyPatch, migrateDb, writeFile, type Action } from "../actions"; +import { log } from "../log"; +import { appDir, ensureDirExists, patchesDir } from "../paths"; + +export async function executeSteps( + actions: Action[], + { + untilStep, + }: { + untilStep?: number; + }, +): Promise { + for (const action of actions) { + if (untilStep && action.step === untilStep) { + log("info", `Stopping before step ${action.step}`); + process.exit(0); + } + + const kind = action.kind; + log("info", `${chalk.bold(`[step ${action.step}]`)} ${kind}`); + + // Prepare the patches directory + await ensureDirExists(patchesDir); + + try { + switch (kind) { + case "diff": + await applyPatch(appDir, action); + break; + case "write": + await writeFile(appDir, action); + break; + case "migrate-db": + await migrateDb(appDir, `step-${action.step}`); + break; + default: + kind satisfies never; + } + } catch (err) { + log("error", `Error in step ${action.step}:\n\n${err}`); + process.exit(1); + } + } +} diff --git a/web/tutorial-app-generator/src/index.ts b/web/tutorial-app-generator/src/index.ts new file mode 100644 index 0000000000..c3cb38ae06 --- /dev/null +++ b/web/tutorial-app-generator/src/index.ts @@ -0,0 +1,70 @@ +import path from "path"; + +import { $ } from "zx"; + +import { program } from "@commander-js/extra-typings"; +import type { Action } from "./actions/index"; +import { getActionsFromTutorialFiles } from "./markdown/extractSteps"; +import { appDir } from "./paths"; +import { waspNew } from "./waspCli"; + +import { updateBrokenDiffs } from "./edit"; +import { executeSteps } from "./execute-steps"; + +const actions: Action[] = await getActionsFromTutorialFiles(); + +const { brokenDiff, untilStep } = program + .option( + "-s, --until-step ", + "Run until the given step. If not provided, run all steps.", + (value: string) => { + const step = parseInt(value, 10); + if (isNaN(step) || step < 1) { + throw new Error("Step must be a positive integer."); + } + return step; + }, + ) + .option( + "-e, --broken-diff ", + "Edit mode, you will edit the diff interactively and all the steps related to the same file that come after.", + (value: string) => { + const step = parseInt(value, 10); + if (isNaN(step) || step < 1) { + throw new Error("Step must be a positive integer."); + } + const actionAtStep = actions.find((action) => action.step === step); + if (!actionAtStep) { + throw new Error(`No action found for step ${step}.`); + } + if (actionAtStep.kind !== "diff") { + throw new Error(`Action at step ${step} is not a diff action.`); + } + return actionAtStep; + }, + ) + .parse(process.argv) + .opts(); + +$.verbose = true; + +async function prepareApp() { + await $`rm -rf ${appDir}`; + await waspNew(appDir); + // TODO: Maybe we should have a whitelist of files we want to keep in src? + await $`rm ${path.join(appDir, "src/Main.css")}`; + await $`rm ${path.join(appDir, "src/waspLogo.png")}`; + await $`rm ${path.join(appDir, "src/MainPage.jsx")}`; + // Git needs to be initialized for patches to work + await $`cd ${appDir} && git init`; +} + +await prepareApp(); + +if (brokenDiff) { + await updateBrokenDiffs(brokenDiff, actions); +} else { + await executeSteps(actions, { + untilStep, + }); +} diff --git a/web/tutorial/src/log.ts b/web/tutorial-app-generator/src/log.ts similarity index 51% rename from web/tutorial/src/log.ts rename to web/tutorial-app-generator/src/log.ts index a37bf1697d..120dad6b17 100644 --- a/web/tutorial/src/log.ts +++ b/web/tutorial-app-generator/src/log.ts @@ -1,10 +1,12 @@ -import { chalk } from 'zx' +import { chalk } from "zx"; const colors = { info: chalk.blue, error: chalk.red, -} +}; export function log(level: keyof typeof colors, message: string) { - console.log(colors[level](`[${level.toUpperCase()}] ${chalk.reset(message)}`)) + console.log( + colors[level](`[${level.toUpperCase()}] ${chalk.reset(message)}`), + ); } diff --git a/web/tutorial/src/markdown/customHeadingId.ts b/web/tutorial-app-generator/src/markdown/customHeadingId.ts similarity index 55% rename from web/tutorial/src/markdown/customHeadingId.ts rename to web/tutorial-app-generator/src/markdown/customHeadingId.ts index d2ddb12484..ffa5a133ee 100644 --- a/web/tutorial/src/markdown/customHeadingId.ts +++ b/web/tutorial-app-generator/src/markdown/customHeadingId.ts @@ -1,13 +1,13 @@ -import type { Options } from 'mdast-util-to-markdown' +import type { Options } from "mdast-util-to-markdown"; -declare module 'mdast' { +declare module "mdast" { interface IdString extends Node { - type: 'idString' - value: string + type: "idString"; + value: string; } interface RootContentMap { - idString: IdString + idString: IdString; } } @@ -15,4 +15,4 @@ export const customHeadingId: Options = { handlers: { idString: (node) => `{#${node.value}}`, }, -} +}; diff --git a/web/tutorial-app-generator/src/markdown/extractSteps.ts b/web/tutorial-app-generator/src/markdown/extractSteps.ts new file mode 100644 index 0000000000..21bf37306c --- /dev/null +++ b/web/tutorial-app-generator/src/markdown/extractSteps.ts @@ -0,0 +1,128 @@ +import fs from "fs/promises"; +import path from "path"; + +import * as acorn from "acorn"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { mdxJsxFromMarkdown, type MdxJsxFlowElement } from "mdast-util-mdx-jsx"; +import { mdxJsx } from "micromark-extension-mdx-jsx"; +import { visit } from "unist-util-visit"; + +import searchAndReplace from "../../../src/remark/search-and-replace.js"; +import { + createApplyPatchAction, + type Action, + type ActionCommon, +} from "../actions/index"; + +const componentName = "TutorialAction"; + +export async function getActionsFromTutorialFiles(): Promise { + const files = await fs + .readdir(path.resolve("../docs/tutorial")) + .then((files) => + files + .filter((file) => file.endsWith(".md")) + .sort((a, b) => { + const aNumber = parseInt(a.split("-")[0]!, 10); + const bNumber = parseInt(b.split("-")[0]!, 10); + return aNumber - bNumber; + }), + ); + const actions: Action[] = []; + for (const file of files) { + console.log(`Processing file: ${file}`); + const fileActions = await getActionsFromFile( + path.resolve("../docs/tutorial", file), + ); + actions.push(...fileActions); + } + return actions; +} + +async function getActionsFromFile(filePath: string): Promise { + const actions = [] as Action[]; + const doc = await fs.readFile(path.resolve(filePath)); + + const ast = fromMarkdown(doc, { + extensions: [mdxJsx({ acorn, addResult: true })], + mdastExtensions: [mdxJsxFromMarkdown()], + }); + + // TODO: figure this out + // @ts-ignore + searchAndReplace.visitor(ast); + + visit(ast, "mdxJsxFlowElement", (node) => { + if (node.name !== componentName) { + return; + } + const step = getStep(node); + const action = getAttributeValue(node, "action"); + + if (!step || !action) { + throw new Error("Step and action attributes are required"); + } + + const commonActionData: ActionCommon = { + step, + markdownSourceFilePath: filePath, + }; + + if (action === "migrate-db") { + actions.push({ + ...commonActionData, + kind: "migrate-db", + }); + return; + } + + if (action === "diff") { + const patchAction = createApplyPatchAction(commonActionData); + actions.push(patchAction); + return; + } + + if (node.children.length !== 1) { + throw new Error(`${componentName} must have exactly one child`); + } + + const childCode = node.children[0]; + if (childCode === undefined || childCode.type !== "code") { + throw new Error(`${componentName} must have a code child`); + } + + const codeBlockCode = childCode.value; + + if (action === "write") { + const path = getAttributeValue(node, "path"); + if (!path) { + throw new Error("Path attribute is required for write action"); + } + actions.push({ + ...commonActionData, + kind: "write", + content: codeBlockCode, + path, + }); + } + }); + + return actions; +} + +function getStep(node: MdxJsxFlowElement): number | null { + const step = getAttributeValue(node, "step"); + return step !== null ? parseInt(step, 10) : null; +} + +function getAttributeValue( + node: MdxJsxFlowElement, + attributeName: string, +): string | null { + const attribute = node.attributes.find( + (attr) => attr.type === "mdxJsxAttribute" && attr.name === attributeName, + ); + return attribute && typeof attribute.value === "string" + ? attribute.value + : null; +} diff --git a/web/tutorial-app-generator/src/paths.ts b/web/tutorial-app-generator/src/paths.ts new file mode 100644 index 0000000000..7fe42df102 --- /dev/null +++ b/web/tutorial-app-generator/src/paths.ts @@ -0,0 +1,8 @@ +import { promises as fs } from "fs"; + +export const appDir = "TodoApp"; +export const patchesDir = "patches"; + +export async function ensureDirExists(dir: string): Promise { + await fs.mkdir(dir, { recursive: true }); +} diff --git a/web/tutorial-app-generator/src/waspCli.ts b/web/tutorial-app-generator/src/waspCli.ts new file mode 100644 index 0000000000..2c249ea506 --- /dev/null +++ b/web/tutorial-app-generator/src/waspCli.ts @@ -0,0 +1,15 @@ +import { $ } from "zx"; + +export async function waspDbMigrate( + appDir: string, + migrationName: string, +): Promise { + await $({ + // Needs to inhert stdio for `wasp db migrate-dev` to work + stdio: "inherit", + })`cd ${appDir} && wasp db migrate-dev --name ${migrationName}`; +} + +export async function waspNew(appDir: string): Promise { + await $`wasp new ${appDir}`; +} diff --git a/web/tutorial/tsconfig.json b/web/tutorial-app-generator/tsconfig.json similarity index 100% rename from web/tutorial/tsconfig.json rename to web/tutorial-app-generator/tsconfig.json diff --git a/web/tutorial/src/actions/diff.ts b/web/tutorial/src/actions/diff.ts deleted file mode 100644 index fa6e01eb9f..0000000000 --- a/web/tutorial/src/actions/diff.ts +++ /dev/null @@ -1,41 +0,0 @@ -import path from 'path' -import fs from 'fs' - -import parseGitDiff from 'parse-git-diff' - -import type { ActionCommon, ApplyPatchAction } from './index' - -export function createApplyPatchAction( - commonActionData: ActionCommon -): ApplyPatchAction { - const patchContentPath = path.resolve( - '../docs/tutorial', - 'patches', - `step-${commonActionData.step}.patch` - ) - - const patch = fs.readFileSync(patchContentPath, 'utf-8') - - const parsedPatch = parseGitDiff(patch) - - if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { - throw new Error('Invalid patch: no changes found') - } - - if (parsedPatch.files.length > 1) { - throw new Error('Invalid patch: multiple files changed') - } - - if (parsedPatch.files[0].type !== 'ChangedFile') { - throw new Error('Invalid patch: only file changes are supported') - } - - const targetFilePath = parsedPatch.files[0].path - - return { - ...commonActionData, - kind: 'diff', - targetFilePath, - patchContentPath, - } -} diff --git a/web/tutorial/src/actions/index.ts b/web/tutorial/src/actions/index.ts deleted file mode 100644 index 2571749e5e..0000000000 --- a/web/tutorial/src/actions/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -import fs from 'fs/promises' -import path from 'path' - -import { $ } from 'zx' - -import { appDir, patchesDir } from '../paths' -import { log } from '../log' -import { waspDbMigrate } from '../waspCli' - -export type ActionCommon = { - step: number - markdownSourceFilePath: string -} - -export type WriteFileAction = { - kind: 'write' - path: string - content: string -} & ActionCommon - -export type ApplyPatchAction = { - kind: 'diff' - targetFilePath: string - patchContentPath: string -} & ActionCommon - -export type MigrateDbAction = { - kind: 'migrate-db' -} & ActionCommon - -export type Action = WriteFileAction | ApplyPatchAction | MigrateDbAction - -export async function writeFileToAppDir(file: WriteFileAction) { - const filePath = path.resolve(appDir, file.path) - await fs.writeFile(filePath, file.content) - log('info', `Wrote to ${file.path}`) -} - -export async function applyPatch(patch: ApplyPatchAction) { - // const patchPath = path.resolve(patchesDir, `step-${patch.step}.patch`) - // await fs.writeFile(patchPath, patch.patch) - await $`cd ${appDir} && git apply ${patch.patchContentPath} --verbose`.quiet( - true - ) - log('info', `Applied patch to ${patch.targetFilePath}`) -} - -export const migrateDb = waspDbMigrate - -export { createApplyPatchAction } from './diff' diff --git a/web/tutorial/src/edit/generate-patch.ts b/web/tutorial/src/edit/generate-patch.ts deleted file mode 100644 index d0937281f8..0000000000 --- a/web/tutorial/src/edit/generate-patch.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { $ } from 'zx' -import { appDir } from '../paths' - -export async function makeCheckpoint(): Promise { - await $`cd ${appDir} && git add . && git commit -m "checkpoint"`.verbose( - false - ) -} - -export async function generateGitPatch(): Promise { - const { stdout: patch } = await $`cd ${appDir} && git diff`.verbose(false) - return patch -} diff --git a/web/tutorial/src/edit/index.ts b/web/tutorial/src/edit/index.ts deleted file mode 100644 index 88e83cf637..0000000000 --- a/web/tutorial/src/edit/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import fs from 'fs/promises' -import Enquirer from 'enquirer' - -import type { Action, ApplyPatchAction } from '../actions' -import { executeSteps } from '../execute-steps' -import { generateGitPatch, makeCheckpoint } from './generate-patch' -import { log } from '../log' - -export async function updateBrokenDiffs( - editDiff: ApplyPatchAction, - actions: Action[] -) { - const actionsBeforeStep = actions.filter( - (action) => action.step < editDiff.step - ) - await executeSteps(actionsBeforeStep, { - untilStep: editDiff.step, - }) - const diffActionsForSameFile = actions - .filter((action) => action.kind === 'diff') - .filter( - (action) => - action.targetFilePath === editDiff.targetFilePath && - action.step >= editDiff.step - ) - - log( - 'info', - `We are now going to edit all the steps for file ${editDiff.targetFilePath} from step ${editDiff.step} onwards` - ) - - for (const action of diffActionsForSameFile) { - const { step, targetFilePath, patchContentPath } = action - await makeCheckpoint() - await Enquirer.prompt({ - type: 'confirm', - name: 'edit', - message: `Apply the new edit to ${targetFilePath} at step ${step} and press Enter`, - initial: true, - }) - const patch = await generateGitPatch() - await fs.writeFile(patchContentPath, patch, 'utf-8') - log('info', `Patch for step ${step} written to ${patchContentPath}.`) - } -} diff --git a/web/tutorial/src/execute-steps/index.ts b/web/tutorial/src/execute-steps/index.ts deleted file mode 100644 index 52147e847f..0000000000 --- a/web/tutorial/src/execute-steps/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { chalk } from 'zx' -import { - applyPatch, - migrateDb, - writeFileToAppDir, - type Action, -} from '../actions' -import { log } from '../log' -import { ensureDirExists, patchesDir } from '../paths' - -export async function executeSteps( - actions: Action[], - { - untilStep, - }: { - untilStep?: number - } -): Promise { - for (const action of actions) { - if (untilStep && action.step === untilStep) { - log('info', `Stopping before step ${action.step}`) - process.exit(0) - } - - const kind = action.kind - log('info', `${chalk.bold(`[step ${action.step}]`)} ${kind}`) - - // Prepare the patches directory - await ensureDirExists(patchesDir) - - try { - switch (kind) { - case 'diff': - await applyPatch(action) - break - case 'write': - await writeFileToAppDir(action) - break - case 'migrate-db': - await migrateDb(`step-${action.step}`) - break - default: - kind satisfies never - } - } catch (err) { - log('error', `Error in step ${action.step}:\n\n${err}`) - process.exit(1) - } - } -} diff --git a/web/tutorial/src/index.ts b/web/tutorial/src/index.ts deleted file mode 100644 index 174415f0df..0000000000 --- a/web/tutorial/src/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import path from 'path' - -import { $ } from 'zx' - -import type { Action } from './actions/index' -import { appDir } from './paths' -import { waspNew } from './waspCli' -import { getActionsFromTutorialFiles } from './markdown/extractSteps' -import { program } from '@commander-js/extra-typings' - -import { executeSteps } from './execute-steps' -import { updateBrokenDiffs } from './edit' - -const actions: Action[] = await getActionsFromTutorialFiles() - -const { brokenDiff, untilStep } = program - .option( - '-s, --until-step ', - 'Run until the given step. If not provided, run all steps.', - (value: string) => { - const step = parseInt(value, 10) - if (isNaN(step) || step < 1) { - throw new Error('Step must be a positive integer.') - } - return step - } - ) - .option( - '-e, --broken-diff ', - 'Edit mode, you will edit the diff interactively and all the steps related to the same file that come after.', - (value: string) => { - const step = parseInt(value, 10) - if (isNaN(step) || step < 1) { - throw new Error('Step must be a positive integer.') - } - const actionAtStep = actions.find((action) => action.step === step) - if (!actionAtStep) { - throw new Error(`No action found for step ${step}.`) - } - if (actionAtStep.kind !== 'diff') { - throw new Error(`Action at step ${step} is not a diff action.`) - } - return actionAtStep - } - ) - .parse(process.argv) - .opts() - -$.verbose = true - -async function prepareApp() { - await $`rm -rf ${appDir}` - await waspNew(appDir) - // TODO: Maybe we should have a whitelist of files we want to keep in src? - await $`rm ${path.join(appDir, 'src/Main.css')}` - await $`rm ${path.join(appDir, 'src/waspLogo.png')}` - await $`rm ${path.join(appDir, 'src/MainPage.jsx')}` - // Git needs to be initialized for patches to work - await $`cd ${appDir} && git init` -} - -await prepareApp() - -if (brokenDiff) { - await updateBrokenDiffs(brokenDiff, actions) -} else { - await executeSteps(actions, { - untilStep, - }) -} diff --git a/web/tutorial/src/markdown/extractSteps.ts b/web/tutorial/src/markdown/extractSteps.ts deleted file mode 100644 index d7755e6ac2..0000000000 --- a/web/tutorial/src/markdown/extractSteps.ts +++ /dev/null @@ -1,128 +0,0 @@ -import fs from 'fs/promises' -import path from 'path' - -import * as acorn from 'acorn' -import { mdxJsx } from 'micromark-extension-mdx-jsx' -import { fromMarkdown } from 'mdast-util-from-markdown' -import { mdxJsxFromMarkdown, type MdxJsxFlowElement } from 'mdast-util-mdx-jsx' -import { visit } from 'unist-util-visit' - -import { - type Action, - type ActionCommon, - createApplyPatchAction, -} from '../actions/index' -import searchAndReplace from '../../../src/remark/search-and-replace.js' - -const componentName = 'TutorialAction' - -export async function getActionsFromTutorialFiles(): Promise { - const files = await fs - .readdir(path.resolve('../docs/tutorial')) - .then((files) => - files - .filter((file) => file.endsWith('.md')) - .sort((a, b) => { - const aNumber = parseInt(a.split('-')[0]!, 10) - const bNumber = parseInt(b.split('-')[0]!, 10) - return aNumber - bNumber - }) - ) - const actions: Action[] = [] - for (const file of files) { - console.log(`Processing file: ${file}`) - const fileActions = await getActionsFromFile( - path.resolve('../docs/tutorial', file) - ) - actions.push(...fileActions) - } - return actions -} - -async function getActionsFromFile(filePath: string): Promise { - const actions = [] as Action[] - const doc = await fs.readFile(path.resolve(filePath)) - - const ast = fromMarkdown(doc, { - extensions: [mdxJsx({ acorn, addResult: true })], - mdastExtensions: [mdxJsxFromMarkdown()], - }) - - // TODO: figure this out - // @ts-ignore - searchAndReplace.visitor(ast) - - visit(ast, 'mdxJsxFlowElement', (node) => { - if (node.name !== componentName) { - return - } - const step = getStep(node) - const action = getAttributeValue(node, 'action') - - if (!step || !action) { - throw new Error('Step and action attributes are required') - } - - const commonActionData: ActionCommon = { - step, - markdownSourceFilePath: filePath, - } - - if (action === 'migrate-db') { - actions.push({ - ...commonActionData, - kind: 'migrate-db', - }) - return - } - - if (action === 'diff') { - const patchAction = createApplyPatchAction(commonActionData) - actions.push(patchAction) - return - } - - if (node.children.length !== 1) { - throw new Error(`${componentName} must have exactly one child`) - } - - const childCode = node.children[0] - if (childCode === undefined || childCode.type !== 'code') { - throw new Error(`${componentName} must have a code child`) - } - - const codeBlockCode = childCode.value - - if (action === 'write') { - const path = getAttributeValue(node, 'path') - if (!path) { - throw new Error('Path attribute is required for write action') - } - actions.push({ - ...commonActionData, - kind: 'write', - content: codeBlockCode, - path, - }) - } - }) - - return actions -} - -function getStep(node: MdxJsxFlowElement): number | null { - const step = getAttributeValue(node, 'step') - return step !== null ? parseInt(step, 10) : null -} - -function getAttributeValue( - node: MdxJsxFlowElement, - attributeName: string -): string | null { - const attribute = node.attributes.find( - (attr) => attr.type === 'mdxJsxAttribute' && attr.name === attributeName - ) - return attribute && typeof attribute.value === 'string' - ? attribute.value - : null -} diff --git a/web/tutorial/src/paths.ts b/web/tutorial/src/paths.ts deleted file mode 100644 index 552c2a9459..0000000000 --- a/web/tutorial/src/paths.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { promises as fs } from 'fs' - -export const appDir = 'TodoApp' -export const patchesDir = 'patches' - -export async function ensureDirExists(dir: string): Promise { - await fs.mkdir(dir, { recursive: true }) -} diff --git a/web/tutorial/src/waspCli.ts b/web/tutorial/src/waspCli.ts deleted file mode 100644 index 69ba5d413b..0000000000 --- a/web/tutorial/src/waspCli.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { $ } from 'zx' - -import { appDir } from './paths' - -export async function waspDbMigrate(migrationName: string): Promise { - await $({ - // Needs to inhert stdio for `wasp db migrate-dev` to work - stdio: 'inherit', - })`cd ${appDir} && wasp db migrate-dev --name ${migrationName}` -} - -export async function waspNew(appName: string): Promise { - await $`wasp new ${appDir}` -} From 2a41ada1572e83ab3480aea78afd728af6bc9790 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 16 Jul 2025 17:42:39 +0200 Subject: [PATCH 08/48] Named patches WIP --- web/docs/tutorial/03-pages.md | 6 +- web/docs/tutorial/04-entities.md | 4 +- web/docs/tutorial/05-queries.md | 6 +- web/docs/tutorial/06-actions.md | 14 +- web/docs/tutorial/07-auth.md | 26 +-- .../{step-26.patch => action-add-auth.patch} | 38 ++--- ...{step-8.patch => action-create-task.patch} | 7 +- .../patches/action-update-task-impl.patch | 39 +++++ ...step-12.patch => action-update-task.patch} | 2 +- .../tutorial/patches/main-page-add-auth.patch | 19 +++ .../patches/main-page-add-logout.patch | 19 +++ .../main-page-create-task-impl-form.patch | 80 +++++++++ ...h => main-page-create-task-use-form.patch} | 6 +- ...p-14.patch => main-page-update-task.patch} | 36 ++-- .../tutorial/patches/prepare-project.patch | 160 ++++++++++++++++++ ...3.patch => prisma-connect-task-user.patch} | 16 +- .../{step-3.patch => prisma-task.patch} | 0 web/docs/tutorial/patches/prisma-user.patch | 12 ++ .../tutorial/patches/query-add-auth.patch | 24 +++ .../{step-5.patch => query-get-tasks.patch} | 5 +- web/docs/tutorial/patches/step-10.patch | 48 ------ web/docs/tutorial/patches/step-13.patch | 32 ---- web/docs/tutorial/patches/step-15.patch | 15 -- web/docs/tutorial/patches/step-22.patch | 15 -- web/docs/tutorial/patches/step-25.patch | 20 --- web/docs/tutorial/patches/step-27.patch | 20 --- ...21.patch => wasp-file-auth-required.patch} | 2 +- ...p-18.patch => wasp-file-auth-routes.patch} | 2 +- .../{step-16.patch => wasp-file-auth.patch} | 4 +- .../src/actions/diff.ts | 41 ----- .../src/actions/index.ts | 70 ++++++-- web/tutorial-app-generator/src/diff.ts | 13 ++ .../src/edit/generate-patch.ts | 12 -- web/tutorial-app-generator/src/edit/index.ts | 46 ----- .../src/execute-steps/index.ts | 15 +- web/tutorial-app-generator/src/index.ts | 66 +++----- .../src/markdown/extractSteps.ts | 5 +- web/tutorial-app-generator/src/waspCli.ts | 2 +- 38 files changed, 551 insertions(+), 396 deletions(-) rename web/docs/tutorial/patches/{step-26.patch => action-add-auth.patch} (58%) rename web/docs/tutorial/patches/{step-8.patch => action-create-task.patch} (55%) create mode 100644 web/docs/tutorial/patches/action-update-task-impl.patch rename web/docs/tutorial/patches/{step-12.patch => action-update-task.patch} (90%) create mode 100644 web/docs/tutorial/patches/main-page-add-auth.patch create mode 100644 web/docs/tutorial/patches/main-page-add-logout.patch create mode 100644 web/docs/tutorial/patches/main-page-create-task-impl-form.patch rename web/docs/tutorial/patches/{step-11.patch => main-page-create-task-use-form.patch} (62%) rename web/docs/tutorial/patches/{step-14.patch => main-page-update-task.patch} (56%) create mode 100644 web/docs/tutorial/patches/prepare-project.patch rename web/docs/tutorial/patches/{step-23.patch => prisma-connect-task-user.patch} (74%) rename web/docs/tutorial/patches/{step-3.patch => prisma-task.patch} (100%) create mode 100644 web/docs/tutorial/patches/prisma-user.patch create mode 100644 web/docs/tutorial/patches/query-add-auth.patch rename web/docs/tutorial/patches/{step-5.patch => query-get-tasks.patch} (90%) delete mode 100644 web/docs/tutorial/patches/step-10.patch delete mode 100644 web/docs/tutorial/patches/step-13.patch delete mode 100644 web/docs/tutorial/patches/step-15.patch delete mode 100644 web/docs/tutorial/patches/step-22.patch delete mode 100644 web/docs/tutorial/patches/step-25.patch delete mode 100644 web/docs/tutorial/patches/step-27.patch rename web/docs/tutorial/patches/{step-21.patch => wasp-file-auth-required.patch} (89%) rename web/docs/tutorial/patches/{step-18.patch => wasp-file-auth-routes.patch} (93%) rename web/docs/tutorial/patches/{step-16.patch => wasp-file-auth.patch} (90%) delete mode 100644 web/tutorial-app-generator/src/actions/diff.ts create mode 100644 web/tutorial-app-generator/src/diff.ts delete mode 100644 web/tutorial-app-generator/src/edit/generate-patch.ts delete mode 100644 web/tutorial-app-generator/src/edit/index.ts diff --git a/web/docs/tutorial/03-pages.md b/web/docs/tutorial/03-pages.md index 7e4f435299..f0be20fc84 100644 --- a/web/docs/tutorial/03-pages.md +++ b/web/docs/tutorial/03-pages.md @@ -97,9 +97,11 @@ Now that you've seen how Wasp deals with Routes and Pages, it's finally time to Start by cleaning up the starter project and removing unnecessary code and files. + + First, remove most of the code from the `MainPage` component: - + ```tsx title="src/MainPage.tsx" auto-js export const MainPage = () => { @@ -119,7 +121,7 @@ Since `src/HelloPage.{jsx,tsx}` no longer exists, remove its `route` and `page` Your Wasp file should now look like this: - + ```wasp title="main.wasp" app TodoApp { diff --git a/web/docs/tutorial/04-entities.md b/web/docs/tutorial/04-entities.md index e1038bbda8..1b12c80479 100644 --- a/web/docs/tutorial/04-entities.md +++ b/web/docs/tutorial/04-entities.md @@ -11,7 +11,7 @@ Wasp uses Prisma to talk to the database, and you define Entities by defining Pr Since our Todo app is all about tasks, we'll define a Task entity by adding a Task model in the `schema.prisma` file: - + ```prisma title="schema.prisma" // ... @@ -29,7 +29,7 @@ Read more about how Wasp Entities work in the [Entities](../data-model/entities. To update the database schema to include this entity, stop the `wasp start` process, if it's running, and run: - + ```sh wasp db migrate-dev ``` diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index d393bc1038..c9de60ea8e 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -43,7 +43,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis
- + ```wasp title="main.wasp" // ... @@ -74,7 +74,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis Next, create a new file called `src/queries.ts` and define the TypeScript function we've just imported in our `query` declaration: - + ```ts title="src/queries.ts" auto-js import type { Task } from 'wasp/entities' @@ -122,7 +122,7 @@ While we implement Queries on the server, Wasp generates client-side functions t This makes it easy for us to use the `getTasks` Query we just created in our React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { Task } from 'wasp/entities' diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index d3fbfcea7e..b7828ff84f 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -24,7 +24,7 @@ Creating an Action is very similar to creating a Query. We must first declare the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -39,7 +39,7 @@ action createTask { Let's now define a JavaScriptTypeScript function for our `createTask` Action: - + ```ts title="src/actions.ts" auto-js import type { Task } from 'wasp/entities' @@ -72,7 +72,7 @@ We put the function in a new file `src/actions.{js,ts}`, but we could have put i Start by defining a form for creating new tasks. - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from 'react' @@ -118,7 +118,7 @@ Unlike Queries, you can call Actions directly (without wrapping them in a hook) All that's left now is adding this form to the page component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from 'react' @@ -175,7 +175,7 @@ Since we've already created one task together, try to create this one yourself. Declaring the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -186,7 +186,7 @@ Since we've already created one task together, try to create this one yourself. } ``` - + Implementing the Action on the server: @@ -213,7 +213,7 @@ Since we've already created one task together, try to create this one yourself. You can now call `updateTask` from the React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent, ChangeEvent } from 'react' diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 6e51bc6be0..38b4eb23b9 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -24,7 +24,7 @@ Since Wasp manages authentication, it will create [the auth related entities](.. You must only add the `User` Entity to keep track of who owns which tasks: - + ```prisma title="schema.prisma" // ... @@ -38,7 +38,7 @@ model User { Next, tell Wasp to use full-stack [authentication](../auth/overview): - + ```wasp title="main.wasp" app TodoApp { @@ -65,7 +65,7 @@ app TodoApp { Don't forget to update the database schema by running: - + ```sh wasp db migrate-dev @@ -86,7 +86,7 @@ Wasp also supports authentication using [Google](../auth/social-auth/google), [G Wasp creates the login and signup forms for us, but we still need to define the pages to display those forms on. We'll start by declaring the pages in the Wasp file: - + ```wasp title="main.wasp" // ... @@ -106,7 +106,7 @@ Great, Wasp now knows these pages exist! Here's the React code for the pages you've just imported: - + ```tsx title="src/LoginPage.tsx" auto-js import { Link } from 'react-router-dom' @@ -129,7 +129,7 @@ export const LoginPage = () => { The signup page is very similar to the login page: - + ```tsx title="src/SignupPage.tsx" auto-js import { Link } from 'react-router-dom' @@ -160,7 +160,7 @@ export const SignupPage = () => { We don't want users who are not logged in to access the main page, because they won't be able to create any tasks. So let's make the page private by requiring the user to be logged in: - + ```wasp title="main.wasp" // ... @@ -176,7 +176,7 @@ Now that auth is required for this page, unauthenticated users will be redirecte Additionally, when `authRequired` is `true`, the page's React component will be provided a `user` object as prop. - + ```tsx title="src/MainPage.tsx" auto-js import type { AuthUser } from 'wasp/auth' @@ -212,7 +212,7 @@ However, you will notice that if you try logging in as different users and creat First, let's define a one-to-many relation between users and tasks (check the [Prisma docs on relations](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/relations)): - + ```prisma title="schema.prisma" // ... @@ -236,7 +236,7 @@ model Task { As always, you must migrate the database after changing the Entities: - + ```sh wasp db migrate-dev ``` @@ -253,7 +253,7 @@ Instead, we would do a data migration to take care of those tasks, even if it me Next, let's update the queries and actions to forbid access to non-authenticated users and to operate only on the currently logged-in user's tasks: - + ```ts title="src/queries.ts" auto-js import type { Task } from 'wasp/entities' @@ -275,7 +275,7 @@ export const getTasks: GetTasks = async (args, context) => { } ``` - + ```ts title="src/actions.ts" auto-js import type { Task } from 'wasp/entities' @@ -341,7 +341,7 @@ You will see that each user has their tasks, just as we specified in our code! Last, but not least, let's add the logout functionality: - + ```tsx title="src/MainPage.tsx" auto-js with-hole // ... diff --git a/web/docs/tutorial/patches/step-26.patch b/web/docs/tutorial/patches/action-add-auth.patch similarity index 58% rename from web/docs/tutorial/patches/step-26.patch rename to web/docs/tutorial/patches/action-add-auth.patch index b8e611399f..307737476b 100644 --- a/web/docs/tutorial/patches/step-26.patch +++ b/web/docs/tutorial/patches/action-add-auth.patch @@ -1,38 +1,34 @@ diff --git a/src/actions.ts b/src/actions.ts -index d8372b9..b796bd2 100644 +index 973d821..b4a52e8 100644 --- a/src/actions.ts +++ b/src/actions.ts -@@ -1,4 +1,6 @@ - import type { Task } from 'wasp/entities' -+// highlight-next-line -+import { HttpError } from 'wasp/server' - import type { CreateTask, UpdateTask } from 'wasp/server/operations' +@@ -1,4 +1,5 @@ + import type { Task } from "wasp/entities"; ++import { HttpError } from "wasp/server"; + import type { CreateTask, UpdateTask } from "wasp/server/operations"; - type CreateTaskPayload = Pick -@@ -7,21 +9,33 @@ export const createTask: CreateTask = async ( + type CreateTaskPayload = Pick; +@@ -7,21 +8,28 @@ export const createTask: CreateTask = async ( args, - context + context, ) => { -+ // highlight-start + if (!context.user) { -+ throw new HttpError(401) ++ throw new HttpError(401); + } -+ // highlight-end return context.entities.Task.create({ - data: { description: args.description }, + data: { + description: args.description, -+ // highlight-next-line + user: { connect: { id: context.user.id } }, + }, - }) - } + }); + }; - type UpdateTaskPayload = Pick + type UpdateTaskPayload = Pick; -export const updateTask: UpdateTask = async ( - { id, isDone }, -- context +- context, -) => { - return context.entities.Task.update({ - where: { id }, @@ -43,13 +39,11 @@ index d8372b9..b796bd2 100644 + UpdateTaskPayload, + { count: number } +> = async (args, context) => { -+ // highlight-start + if (!context.user) { -+ throw new HttpError(401) ++ throw new HttpError(401); + } -+ // highlight-end + return context.entities.Task.updateMany({ + where: { id: args.id, user: { id: context.user.id } }, + data: { isDone: args.isDone }, - }) - } + }); + }; diff --git a/web/docs/tutorial/patches/step-8.patch b/web/docs/tutorial/patches/action-create-task.patch similarity index 55% rename from web/docs/tutorial/patches/step-8.patch rename to web/docs/tutorial/patches/action-create-task.patch index f8a1eab9d5..c0952faf52 100644 --- a/web/docs/tutorial/patches/step-8.patch +++ b/web/docs/tutorial/patches/action-create-task.patch @@ -1,11 +1,12 @@ diff --git a/main.wasp b/main.wasp -index b288bf6..e83fe65 100644 +index c3df1d1..9b3c9d8 100644 --- a/main.wasp +++ b/main.wasp -@@ -20,3 +20,7 @@ query getTasks { +@@ -19,3 +19,8 @@ query getTasks { + // automatically update the results of this query when tasks are modified. entities: [Task] } - ++ +action createTask { + fn: import { createTask } from "@src/actions", + entities: [Task] diff --git a/web/docs/tutorial/patches/action-update-task-impl.patch b/web/docs/tutorial/patches/action-update-task-impl.patch new file mode 100644 index 0000000000..f148d40c8e --- /dev/null +++ b/web/docs/tutorial/patches/action-update-task-impl.patch @@ -0,0 +1,39 @@ +diff --git a/src/actions.ts b/src/actions.ts +index a7c1b28..973d821 100644 +--- a/src/actions.ts ++++ b/src/actions.ts +@@ -1,13 +1,27 @@ +-import type { Task } from 'wasp/entities' +-import type { CreateTask } from 'wasp/server/operations' ++import type { Task } from "wasp/entities"; ++import type { CreateTask, UpdateTask } from "wasp/server/operations"; + +-type CreateTaskPayload = Pick ++type CreateTaskPayload = Pick; + + export const createTask: CreateTask = async ( + args, +- context ++ context, + ) => { + return context.entities.Task.create({ + data: { description: args.description }, +- }) +-} +\ No newline at end of file ++ }); ++}; ++ ++type UpdateTaskPayload = Pick; ++ ++export const updateTask: UpdateTask = async ( ++ { id, isDone }, ++ context, ++) => { ++ return context.entities.Task.update({ ++ where: { id }, ++ data: { ++ isDone: isDone, ++ }, ++ }); ++}; diff --git a/web/docs/tutorial/patches/step-12.patch b/web/docs/tutorial/patches/action-update-task.patch similarity index 90% rename from web/docs/tutorial/patches/step-12.patch rename to web/docs/tutorial/patches/action-update-task.patch index 0d0ba4d16f..47c9e59df5 100644 --- a/web/docs/tutorial/patches/step-12.patch +++ b/web/docs/tutorial/patches/action-update-task.patch @@ -1,5 +1,5 @@ diff --git a/main.wasp b/main.wasp -index e83fe65..31483d2 100644 +index 9b3c9d8..eed193e 100644 --- a/main.wasp +++ b/main.wasp @@ -24,3 +24,8 @@ action createTask { diff --git a/web/docs/tutorial/patches/main-page-add-auth.patch b/web/docs/tutorial/patches/main-page-add-auth.patch new file mode 100644 index 0000000000..34322ef103 --- /dev/null +++ b/web/docs/tutorial/patches/main-page-add-auth.patch @@ -0,0 +1,19 @@ +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +index 6c618ef..decefb6 100644 +--- a/src/MainPage.tsx ++++ b/src/MainPage.tsx +@@ -1,4 +1,5 @@ + import type { ChangeEvent, FormEvent } from "react"; ++import type { AuthUser } from "wasp/auth"; + import { + createTask, + getTasks, +@@ -7,7 +8,7 @@ import { + } from "wasp/client/operations"; + import type { Task } from "wasp/entities"; + +-export const MainPage = () => { ++export const MainPage = ({ user }: { user: AuthUser }) => { + // highlight-start + const { data: tasks, isLoading, error } = useQuery(getTasks); + diff --git a/web/docs/tutorial/patches/main-page-add-logout.patch b/web/docs/tutorial/patches/main-page-add-logout.patch new file mode 100644 index 0000000000..73a9282b15 --- /dev/null +++ b/web/docs/tutorial/patches/main-page-add-logout.patch @@ -0,0 +1,19 @@ +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +index decefb6..ddc6fde 100644 +--- a/src/MainPage.tsx ++++ b/src/MainPage.tsx +@@ -1,5 +1,6 @@ + import type { ChangeEvent, FormEvent } from "react"; + import type { AuthUser } from "wasp/auth"; ++import { logout } from "wasp/client/auth"; + import { + createTask, + getTasks, +@@ -19,6 +20,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { + + {isLoading && "Loading..."} + {error && "Error: " + error} ++ + + ); + // highlight-end diff --git a/web/docs/tutorial/patches/main-page-create-task-impl-form.patch b/web/docs/tutorial/patches/main-page-create-task-impl-form.patch new file mode 100644 index 0000000000..e74cc8d324 --- /dev/null +++ b/web/docs/tutorial/patches/main-page-create-task-impl-form.patch @@ -0,0 +1,80 @@ +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +index 556e1a2..eb804b9 100644 +--- a/src/MainPage.tsx ++++ b/src/MainPage.tsx +@@ -1,21 +1,21 @@ +-import type { Task } from 'wasp/entities' +-// highlight-next-line +-import { getTasks, useQuery } from 'wasp/client/operations' ++import type { FormEvent } from "react"; ++import { createTask, getTasks, useQuery } from "wasp/client/operations"; ++import type { Task } from "wasp/entities"; + + export const MainPage = () => { + // highlight-start +- const { data: tasks, isLoading, error } = useQuery(getTasks) ++ const { data: tasks, isLoading, error } = useQuery(getTasks); + + return ( +
+ {tasks && } + +- {isLoading && 'Loading...'} +- {error && 'Error: ' + error} ++ {isLoading && "Loading..."} ++ {error && "Error: " + error} +
+- ) ++ ); + // highlight-end +-} ++}; + + // highlight-start + const TaskView = ({ task }: { task: Task }) => { +@@ -24,11 +24,11 @@ const TaskView = ({ task }: { task: Task }) => { + + {task.description} + +- ) +-} ++ ); ++}; + + const TasksList = ({ tasks }: { tasks: Task[] }) => { +- if (!tasks?.length) return
No tasks
++ if (!tasks?.length) return
No tasks
; + + return ( +
+@@ -36,6 +36,26 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { + + ))} +
+- ) +-} +-// highlight-end +\ No newline at end of file ++ ); ++}; ++ ++const NewTaskForm = () => { ++ const handleSubmit = async (event: FormEvent) => { ++ event.preventDefault(); ++ try { ++ const target = event.target as HTMLFormElement; ++ const description = target.description.value; ++ target.reset(); ++ await createTask({ description }); ++ } catch (err: any) { ++ window.alert("Error: " + err.message); ++ } ++ }; ++ ++ return ( ++
++ ++ ++
++ ); ++}; diff --git a/web/docs/tutorial/patches/step-11.patch b/web/docs/tutorial/patches/main-page-create-task-use-form.patch similarity index 62% rename from web/docs/tutorial/patches/step-11.patch rename to web/docs/tutorial/patches/main-page-create-task-use-form.patch index 42b0e75d10..2f6b8b3dea 100644 --- a/web/docs/tutorial/patches/step-11.patch +++ b/web/docs/tutorial/patches/main-page-create-task-use-form.patch @@ -1,12 +1,12 @@ diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 50118d6..296d0cf 100644 +index eb804b9..cd11965 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx -@@ -13,6 +13,7 @@ export const MainPage = () => { +@@ -8,6 +8,7 @@ export const MainPage = () => { return (
+ {tasks && } - {isLoading && 'Loading...'} + {isLoading && "Loading..."} diff --git a/web/docs/tutorial/patches/step-14.patch b/web/docs/tutorial/patches/main-page-update-task.patch similarity index 56% rename from web/docs/tutorial/patches/step-14.patch rename to web/docs/tutorial/patches/main-page-update-task.patch index 2567a0e0a7..5b9f5daad1 100644 --- a/web/docs/tutorial/patches/step-14.patch +++ b/web/docs/tutorial/patches/main-page-update-task.patch @@ -1,33 +1,34 @@ diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 296d0cf..f02e05e 100644 +index cd11965..6c618ef 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx -@@ -1,7 +1,8 @@ --import type { FormEvent } from 'react' -+import type { FormEvent, ChangeEvent } from 'react' - import type { Task } from 'wasp/entities' - import { - // highlight-next-line +@@ -1,5 +1,10 @@ +-import type { FormEvent } from "react"; +-import { createTask, getTasks, useQuery } from "wasp/client/operations"; ++import type { ChangeEvent, FormEvent } from "react"; ++import { ++ createTask, ++ getTasks, + updateTask, - createTask, - getTasks, - useQuery, -@@ -25,9 +26,28 @@ export const MainPage = () => { ++ useQuery, ++} from "wasp/client/operations"; + import type { Task } from "wasp/entities"; + + export const MainPage = () => { +@@ -20,9 +25,25 @@ export const MainPage = () => { // highlight-start const TaskView = ({ task }: { task: Task }) => { -+ // highlight-start + const handleIsDoneChange = async (event: ChangeEvent) => { + try { + await updateTask({ + id: task.id, + isDone: event.target.checked, -+ }) ++ }); + } catch (error: any) { -+ window.alert('Error while updating task: ' + error.message) ++ window.alert("Error while updating task: " + error.message); + } -+ } -+ // highlight-end ++ }; + return (
@@ -36,9 +37,8 @@ index 296d0cf..f02e05e 100644 + type="checkbox" + id={String(task.id)} + checked={task.isDone} -+ // highlight-next-line + onChange={handleIsDoneChange} + /> {task.description}
- ) + ); diff --git a/web/docs/tutorial/patches/prepare-project.patch b/web/docs/tutorial/patches/prepare-project.patch new file mode 100644 index 0000000000..4bbd5e6f16 --- /dev/null +++ b/web/docs/tutorial/patches/prepare-project.patch @@ -0,0 +1,160 @@ +diff --git a/src/Main.css b/src/Main.css +deleted file mode 100644 +index 9e93c7a..0000000 +--- a/src/Main.css ++++ /dev/null +@@ -1,103 +0,0 @@ +-* { +- -webkit-font-smoothing: antialiased; +- -moz-osx-font-smoothing: grayscale; +- box-sizing: border-box; +- margin: 0; +- padding: 0; +-} +- +-body { +- font-family: +- -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", +- "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; +-} +- +-#root { +- min-height: 100vh; +- display: flex; +- flex-direction: column; +- justify-content: center; +- align-items: center; +-} +- +-.container { +- margin: 3rem 3rem 10rem 3rem; +- max-width: 726px; +- display: flex; +- flex-direction: column; +- justify-content: center; +- align-items: center; +- text-align: center; +-} +- +-.logo { +- max-height: 200px; +- margin-bottom: 1rem; +-} +- +-.title { +- font-size: 4rem; +- font-weight: 700; +- margin-bottom: 1rem; +-} +- +-.content { +- font-size: 1.2rem; +- font-weight: 400; +- line-height: 2; +- margin-bottom: 3rem; +-} +- +-.buttons { +- display: flex; +- flex-direction: row; +- gap: 1rem; +-} +- +-.button { +- font-size: 1.2rem; +- font-weight: 700; +- text-decoration: none; +- padding: 1.2rem 1.5rem; +- border-radius: 10px; +-} +- +-.button-filled { +- color: black; +- background-color: #ffcc00; +- border: 2px solid #ffcc00; +- +- transition: all 0.2s ease-in-out; +-} +- +-.button-filled:hover { +- filter: brightness(0.95); +-} +- +-.button-outlined { +- color: black; +- background-color: transparent; +- border: 2px solid #ffcc00; +- +- transition: all 0.2s ease-in-out; +-} +- +-.button-outlined:hover { +- filter: brightness(0.95); +-} +- +-code { +- border-radius: 5px; +- border: 1px solid #ffcc00; +- padding: 0.2rem; +- background: #ffcc0044; +- font-family: +- Menlo, +- Monaco, +- Lucida Console, +- Liberation Mono, +- DejaVu Sans Mono, +- Bitstream Vera Sans Mono, +- Courier New, +- monospace; +-} +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +deleted file mode 100644 +index bdb62b3..0000000 +--- a/src/MainPage.tsx ++++ /dev/null +@@ -1,37 +0,0 @@ +-import Logo from "./assets/logo.svg"; +-import "./Main.css"; +- +-export function MainPage() { +- return ( +-
+- wasp +- +-

Welcome to Wasp!

+- +-

+- This is page MainPage located at route /. +-
+- Open src/MainPage.tsx to edit it. +-

+- +- +-
+- ); +-} +diff --git a/src/assets/logo.svg b/src/assets/logo.svg +deleted file mode 100644 +index faa99ae..0000000 +--- a/src/assets/logo.svg ++++ /dev/null +@@ -1 +0,0 @@ +- +\ No newline at end of file diff --git a/web/docs/tutorial/patches/step-23.patch b/web/docs/tutorial/patches/prisma-connect-task-user.patch similarity index 74% rename from web/docs/tutorial/patches/step-23.patch rename to web/docs/tutorial/patches/prisma-connect-task-user.patch index bce380d8aa..fd50fa7d72 100644 --- a/web/docs/tutorial/patches/step-23.patch +++ b/web/docs/tutorial/patches/prisma-connect-task-user.patch @@ -1,20 +1,24 @@ diff --git a/schema.prisma b/schema.prisma -index e07f47c..aad0a3c 100644 +index 76c3741..aad0a3c 100644 --- a/schema.prisma +++ b/schema.prisma -@@ -10,11 +10,14 @@ generator client { +@@ -9,12 +9,15 @@ generator client { + provider = "prisma-client-js" } - model User { -- id Int @id @default(autoincrement()) ++model User { + id Int @id @default(autoincrement()) + tasks Task[] - } - ++} ++ model Task { id Int @id @default(autoincrement()) description String isDone Boolean @default(false) +-} +- +-model User { +- id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id]) + userId Int? } diff --git a/web/docs/tutorial/patches/step-3.patch b/web/docs/tutorial/patches/prisma-task.patch similarity index 100% rename from web/docs/tutorial/patches/step-3.patch rename to web/docs/tutorial/patches/prisma-task.patch diff --git a/web/docs/tutorial/patches/prisma-user.patch b/web/docs/tutorial/patches/prisma-user.patch new file mode 100644 index 0000000000..0966ebfcd3 --- /dev/null +++ b/web/docs/tutorial/patches/prisma-user.patch @@ -0,0 +1,12 @@ +diff --git a/schema.prisma b/schema.prisma +index 65d7d30..76c3741 100644 +--- a/schema.prisma ++++ b/schema.prisma +@@ -14,3 +14,7 @@ model Task { + description String + isDone Boolean @default(false) + } ++ ++model User { ++ id Int @id @default(autoincrement()) ++} diff --git a/web/docs/tutorial/patches/query-add-auth.patch b/web/docs/tutorial/patches/query-add-auth.patch new file mode 100644 index 0000000000..8bb0c00d36 --- /dev/null +++ b/web/docs/tutorial/patches/query-add-auth.patch @@ -0,0 +1,24 @@ +diff --git a/src/queries.ts b/src/queries.ts +index 1738f22..92a513e 100644 +--- a/src/queries.ts ++++ b/src/queries.ts +@@ -1,8 +1,13 @@ +-import type { Task } from 'wasp/entities' +-import type { GetTasks } from 'wasp/server/operations' ++import type { Task } from "wasp/entities"; ++import { HttpError } from "wasp/server"; ++import type { GetTasks } from "wasp/server/operations"; + + export const getTasks: GetTasks = async (args, context) => { ++ if (!context.user) { ++ throw new HttpError(401); ++ } + return context.entities.Task.findMany({ +- orderBy: { id: 'asc' }, +- }) +-} +\ No newline at end of file ++ where: { user: { id: context.user.id } }, ++ orderBy: { id: "asc" }, ++ }); ++}; diff --git a/web/docs/tutorial/patches/step-5.patch b/web/docs/tutorial/patches/query-get-tasks.patch similarity index 90% rename from web/docs/tutorial/patches/step-5.patch rename to web/docs/tutorial/patches/query-get-tasks.patch index 71374439ad..f8727ede55 100644 --- a/web/docs/tutorial/patches/step-5.patch +++ b/web/docs/tutorial/patches/query-get-tasks.patch @@ -1,8 +1,8 @@ diff --git a/main.wasp b/main.wasp -index 3a25ea9..ea22c79 100644 +index 6b4593c..c3df1d1 100644 --- a/main.wasp +++ b/main.wasp -@@ -8,4 +8,15 @@ app TodoApp { +@@ -8,4 +8,14 @@ app TodoApp { route RootRoute { path: "/", to: MainPage } page MainPage { component: import { MainPage } from "@src/MainPage" @@ -19,4 +19,3 @@ index 3a25ea9..ea22c79 100644 + // automatically update the results of this query when tasks are modified. + entities: [Task] +} -+ diff --git a/web/docs/tutorial/patches/step-10.patch b/web/docs/tutorial/patches/step-10.patch deleted file mode 100644 index 885aa2e4d7..0000000000 --- a/web/docs/tutorial/patches/step-10.patch +++ /dev/null @@ -1,48 +0,0 @@ -diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 556e1a2..50118d6 100644 ---- a/src/MainPage.tsx -+++ b/src/MainPage.tsx -@@ -1,6 +1,11 @@ -+import type { FormEvent } from 'react' - import type { Task } from 'wasp/entities' --// highlight-next-line --import { getTasks, useQuery } from 'wasp/client/operations' -+import { -+ // highlight-next-line -+ createTask, -+ getTasks, -+ useQuery, -+} from 'wasp/client/operations' - - export const MainPage = () => { - // highlight-start -@@ -38,4 +43,27 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { -
- ) - } --// highlight-end -\ No newline at end of file -+// highlight-end -+ -+// highlight-start -+const NewTaskForm = () => { -+ const handleSubmit = async (event: FormEvent) => { -+ event.preventDefault() -+ try { -+ const target = event.target as HTMLFormElement -+ const description = target.description.value -+ target.reset() -+ await createTask({ description }) -+ } catch (err: any) { -+ window.alert('Error: ' + err.message) -+ } -+ } -+ -+ return ( -+
-+ -+ -+
-+ ) -+} -+// highlight-end diff --git a/web/docs/tutorial/patches/step-13.patch b/web/docs/tutorial/patches/step-13.patch deleted file mode 100644 index 7d48ac8ed2..0000000000 --- a/web/docs/tutorial/patches/step-13.patch +++ /dev/null @@ -1,32 +0,0 @@ -diff --git a/src/actions.ts b/src/actions.ts -index a7c1b28..d8372b9 100644 ---- a/src/actions.ts -+++ b/src/actions.ts -@@ -1,5 +1,5 @@ - import type { Task } from 'wasp/entities' --import type { CreateTask } from 'wasp/server/operations' -+import type { CreateTask, UpdateTask } from 'wasp/server/operations' - - type CreateTaskPayload = Pick - -@@ -10,4 +10,18 @@ export const createTask: CreateTask = async ( - return context.entities.Task.create({ - data: { description: args.description }, - }) --} -\ No newline at end of file -+} -+ -+type UpdateTaskPayload = Pick -+ -+export const updateTask: UpdateTask = async ( -+ { id, isDone }, -+ context -+) => { -+ return context.entities.Task.update({ -+ where: { id }, -+ data: { -+ isDone: isDone, -+ }, -+ }) -+} diff --git a/web/docs/tutorial/patches/step-15.patch b/web/docs/tutorial/patches/step-15.patch deleted file mode 100644 index 2d6393fcfa..0000000000 --- a/web/docs/tutorial/patches/step-15.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/schema.prisma b/schema.prisma -index 65d7d30..e07f47c 100644 ---- a/schema.prisma -+++ b/schema.prisma -@@ -9,6 +9,10 @@ generator client { - provider = "prisma-client-js" - } - -+model User { -+ id Int @id @default(autoincrement()) -+} -+ - model Task { - id Int @id @default(autoincrement()) - description String diff --git a/web/docs/tutorial/patches/step-22.patch b/web/docs/tutorial/patches/step-22.patch deleted file mode 100644 index 47e9a8350c..0000000000 --- a/web/docs/tutorial/patches/step-22.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index f02e05e..a4389aa 100644 ---- a/src/MainPage.tsx -+++ b/src/MainPage.tsx -@@ -7,8 +7,9 @@ import { - getTasks, - useQuery, - } from 'wasp/client/operations' -+import { AuthUser } from 'wasp/auth' - --export const MainPage = () => { -+export const MainPage = ({ user }: { user: AuthUser }) => { - // highlight-start - const { data: tasks, isLoading, error } = useQuery(getTasks) - diff --git a/web/docs/tutorial/patches/step-25.patch b/web/docs/tutorial/patches/step-25.patch deleted file mode 100644 index 1dbba8a062..0000000000 --- a/web/docs/tutorial/patches/step-25.patch +++ /dev/null @@ -1,20 +0,0 @@ -diff --git a/src/queries.ts b/src/queries.ts -index 1738f22..f2ab046 100644 ---- a/src/queries.ts -+++ b/src/queries.ts -@@ -1,8 +1,13 @@ - import type { Task } from 'wasp/entities' -+import { HttpError } from 'wasp/server' - import type { GetTasks } from 'wasp/server/operations' - - export const getTasks: GetTasks = async (args, context) => { -+ if (!context.user) { -+ throw new HttpError(401) -+ } - return context.entities.Task.findMany({ -+ where: { user: { id: context.user.id } }, - orderBy: { id: 'asc' }, - }) --} -\ No newline at end of file -+} diff --git a/web/docs/tutorial/patches/step-27.patch b/web/docs/tutorial/patches/step-27.patch deleted file mode 100644 index f320e30bef..0000000000 --- a/web/docs/tutorial/patches/step-27.patch +++ /dev/null @@ -1,20 +0,0 @@ -diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 6075b9b..b1f94f5 100644 ---- a/src/MainPage.tsx -+++ b/src/MainPage.tsx -@@ -8,6 +8,7 @@ import { - useQuery, - } from 'wasp/client/operations' - import { AuthUser } from 'wasp/auth' -+import { logout } from 'wasp/client/auth' - - export const MainPage = ({ user }: { user: AuthUser }) => { - // highlight-start -@@ -20,6 +21,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { - - {isLoading && 'Loading...'} - {error && 'Error: ' + error} -+ - - ) - // highlight-end diff --git a/web/docs/tutorial/patches/step-21.patch b/web/docs/tutorial/patches/wasp-file-auth-required.patch similarity index 89% rename from web/docs/tutorial/patches/step-21.patch rename to web/docs/tutorial/patches/wasp-file-auth-required.patch index 1d25d45af9..458b674c84 100644 --- a/web/docs/tutorial/patches/step-21.patch +++ b/web/docs/tutorial/patches/wasp-file-auth-required.patch @@ -1,5 +1,5 @@ diff --git a/main.wasp b/main.wasp -index 5e921a0..4f51c6f 100644 +index f2aa7ca..aa5eef1 100644 --- a/main.wasp +++ b/main.wasp @@ -17,6 +17,7 @@ app TodoApp { diff --git a/web/docs/tutorial/patches/step-18.patch b/web/docs/tutorial/patches/wasp-file-auth-routes.patch similarity index 93% rename from web/docs/tutorial/patches/step-18.patch rename to web/docs/tutorial/patches/wasp-file-auth-routes.patch index 22930da750..07d56c73b3 100644 --- a/web/docs/tutorial/patches/step-18.patch +++ b/web/docs/tutorial/patches/wasp-file-auth-routes.patch @@ -1,5 +1,5 @@ diff --git a/main.wasp b/main.wasp -index 3a70018..5e921a0 100644 +index 5592563..f2aa7ca 100644 --- a/main.wasp +++ b/main.wasp @@ -39,3 +39,13 @@ action updateTask { diff --git a/web/docs/tutorial/patches/step-16.patch b/web/docs/tutorial/patches/wasp-file-auth.patch similarity index 90% rename from web/docs/tutorial/patches/step-16.patch rename to web/docs/tutorial/patches/wasp-file-auth.patch index cea17b8658..344a319ac9 100644 --- a/web/docs/tutorial/patches/step-16.patch +++ b/web/docs/tutorial/patches/wasp-file-auth.patch @@ -1,10 +1,10 @@ diff --git a/main.wasp b/main.wasp -index 31483d2..3a70018 100644 +index eed193e..5592563 100644 --- a/main.wasp +++ b/main.wasp @@ -2,7 +2,17 @@ app TodoApp { wasp: { - version: "^0.16.0" + version: "^0.17.0" }, - title: "TodoApp" + title: "TodoApp", diff --git a/web/tutorial-app-generator/src/actions/diff.ts b/web/tutorial-app-generator/src/actions/diff.ts deleted file mode 100644 index 698e84a870..0000000000 --- a/web/tutorial-app-generator/src/actions/diff.ts +++ /dev/null @@ -1,41 +0,0 @@ -import fs from "fs"; -import path from "path"; - -import parseGitDiff from "parse-git-diff"; - -import type { ActionCommon, ApplyPatchAction } from "./index"; - -export function createApplyPatchAction( - commonActionData: ActionCommon, -): ApplyPatchAction { - const patchContentPath = path.resolve( - "../docs/tutorial", - "patches", - `step-${commonActionData.step}.patch`, - ); - - const patch = fs.readFileSync(patchContentPath, "utf-8"); - - const parsedPatch = parseGitDiff(patch); - - if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { - throw new Error("Invalid patch: no changes found"); - } - - if (parsedPatch.files.length > 1) { - throw new Error("Invalid patch: multiple files changed"); - } - - if (parsedPatch.files[0].type !== "ChangedFile") { - throw new Error("Invalid patch: only file changes are supported"); - } - - const targetFilePath = parsedPatch.files[0].path; - - return { - ...commonActionData, - kind: "diff", - targetFilePath, - patchContentPath, - }; -} diff --git a/web/tutorial-app-generator/src/actions/index.ts b/web/tutorial-app-generator/src/actions/index.ts index 0ec61a6477..deb0729217 100644 --- a/web/tutorial-app-generator/src/actions/index.ts +++ b/web/tutorial-app-generator/src/actions/index.ts @@ -3,11 +3,13 @@ import path from "path"; import { $ } from "zx"; +import Enquirer from "enquirer"; +import { assertValidPatchFile } from "../diff"; import { log } from "../log"; import { waspDbMigrate } from "../waspCli"; export type ActionCommon = { - step: number; + step: string; markdownSourceFilePath: string; }; @@ -19,7 +21,6 @@ export type WriteFileAction = { export type ApplyPatchAction = { kind: "diff"; - targetFilePath: string; patchContentPath: string; } & ActionCommon; @@ -29,21 +30,66 @@ export type MigrateDbAction = { export type Action = WriteFileAction | ApplyPatchAction | MigrateDbAction; -export async function writeFile(appDir: string, file: WriteFileAction) { - const filePath = path.resolve(appDir, file.path); - await fs.writeFile(filePath, file.content); - log("info", `Wrote to ${file.path}`); +export async function writeFile(appDir: string, action: WriteFileAction) { + const filePath = path.resolve(appDir, action.path); + await fs.writeFile(filePath, action.content); + log("info", `Wrote to ${action.path}`); } -export async function applyPatch(appDir: string, patch: ApplyPatchAction) { - // const patchPath = path.resolve(patchesDir, `step-${patch.step}.patch`) - // await fs.writeFile(patchPath, patch.patch) - await $`cd ${appDir} && git apply ${patch.patchContentPath} --verbose`.quiet( +export async function ensurePatchExists( + appDir: string, + action: ApplyPatchAction, +) { + const patchPath = path.resolve(appDir, action.patchContentPath); + if (!(await fs.stat(patchPath).catch(() => false))) { + await Enquirer.prompt({ + type: "confirm", + name: "edit", + message: `Apply edit for ${action.step} and press Enter`, + initial: true, + }); + const patch = await generateGitPatch(appDir); + await fs.writeFile(action.patchContentPath, patch, "utf-8"); + log("info", `Patch file created: ${action.patchContentPath}`); + await undoChanges(appDir); + } + assertValidPatchFile(action.patchContentPath); +} + +function undoChanges(appDir: string) { + return $`cd ${appDir} && git reset --hard HEAD && git clean -fd`.quiet(true); +} + +export async function generateGitPatch(appDir: string): Promise { + const { stdout: patch } = await $`cd ${appDir} && git diff`.verbose(false); + return patch; +} + +export async function commitStep(appDir: string, action: ActionCommon) { + await $`cd ${appDir} && git add . && git commit -m "${action.step}" && git tag ${action.step}`; + log("info", `Committed step ${action.step}`); +} + +export async function applyPatch(appDir: string, action: ApplyPatchAction) { + await $`cd ${appDir} && git apply ${action.patchContentPath} --verbose`.quiet( true, ); - log("info", `Applied patch to ${patch.targetFilePath}`); } export const migrateDb = waspDbMigrate; -export { createApplyPatchAction } from "./diff"; +export function createApplyPatchAction( + commonActionData: ActionCommon, +): ApplyPatchAction { + const patchContentPath = path.resolve( + "../docs/tutorial", + "patches", + `${commonActionData.step}.patch`, + ); + + return { + ...commonActionData, + kind: "diff", + patchContentPath, + }; +} diff --git a/web/tutorial-app-generator/src/diff.ts b/web/tutorial-app-generator/src/diff.ts new file mode 100644 index 0000000000..0380608702 --- /dev/null +++ b/web/tutorial-app-generator/src/diff.ts @@ -0,0 +1,13 @@ +import fs from "fs"; + +import parseGitDiff from "parse-git-diff"; + +export function assertValidPatchFile(patchContentPath: string): void { + const patch = fs.readFileSync(patchContentPath, "utf-8"); + + const parsedPatch = parseGitDiff(patch); + + if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { + throw new Error("Invalid patch: no changes found"); + } +} diff --git a/web/tutorial-app-generator/src/edit/generate-patch.ts b/web/tutorial-app-generator/src/edit/generate-patch.ts deleted file mode 100644 index 9a516d1ef1..0000000000 --- a/web/tutorial-app-generator/src/edit/generate-patch.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { $ } from "zx"; - -export async function makeCheckpoint(appDir: string): Promise { - await $`cd ${appDir} && git add . && git commit -m "checkpoint"`.verbose( - false, - ); -} - -export async function generateGitPatch(appDir: string): Promise { - const { stdout: patch } = await $`cd ${appDir} && git diff`.verbose(false); - return patch; -} diff --git a/web/tutorial-app-generator/src/edit/index.ts b/web/tutorial-app-generator/src/edit/index.ts deleted file mode 100644 index 468ac7fbbb..0000000000 --- a/web/tutorial-app-generator/src/edit/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import Enquirer from "enquirer"; -import fs from "fs/promises"; - -import type { Action, ApplyPatchAction } from "../actions"; -import { executeSteps } from "../execute-steps"; -import { log } from "../log"; -import { appDir } from "../paths"; -import { generateGitPatch, makeCheckpoint } from "./generate-patch"; - -export async function updateBrokenDiffs( - editDiff: ApplyPatchAction, - actions: Action[], -) { - const actionsBeforeStep = actions.filter( - (action) => action.step < editDiff.step, - ); - await executeSteps(actionsBeforeStep, { - untilStep: editDiff.step, - }); - const diffActionsForSameFile = actions - .filter((action) => action.kind === "diff") - .filter( - (action) => - action.targetFilePath === editDiff.targetFilePath && - action.step >= editDiff.step, - ); - - log( - "info", - `We are now going to edit all the steps for file ${editDiff.targetFilePath} from step ${editDiff.step} onwards`, - ); - - for (const action of diffActionsForSameFile) { - const { step, targetFilePath, patchContentPath } = action; - await makeCheckpoint(appDir); - await Enquirer.prompt({ - type: "confirm", - name: "edit", - message: `Apply the new edit to ${targetFilePath} at step ${step} and press Enter`, - initial: true, - }); - const patch = await generateGitPatch(appDir); - await fs.writeFile(patchContentPath, patch, "utf-8"); - log("info", `Patch for step ${step} written to ${patchContentPath}.`); - } -} diff --git a/web/tutorial-app-generator/src/execute-steps/index.ts b/web/tutorial-app-generator/src/execute-steps/index.ts index aa77082f36..41c9a81ea3 100644 --- a/web/tutorial-app-generator/src/execute-steps/index.ts +++ b/web/tutorial-app-generator/src/execute-steps/index.ts @@ -1,5 +1,12 @@ import { chalk } from "zx"; -import { applyPatch, migrateDb, writeFile, type Action } from "../actions"; +import { + applyPatch, + commitStep, + ensurePatchExists, + migrateDb, + writeFile, + type Action, +} from "../actions"; import { log } from "../log"; import { appDir, ensureDirExists, patchesDir } from "../paths"; @@ -8,11 +15,11 @@ export async function executeSteps( { untilStep, }: { - untilStep?: number; + untilStep?: Action; }, ): Promise { for (const action of actions) { - if (untilStep && action.step === untilStep) { + if (untilStep && action.step === untilStep.step) { log("info", `Stopping before step ${action.step}`); process.exit(0); } @@ -26,6 +33,7 @@ export async function executeSteps( try { switch (kind) { case "diff": + await ensurePatchExists(appDir, action); await applyPatch(appDir, action); break; case "write": @@ -41,5 +49,6 @@ export async function executeSteps( log("error", `Error in step ${action.step}:\n\n${err}`); process.exit(1); } + await commitStep(appDir, action); } } diff --git a/web/tutorial-app-generator/src/index.ts b/web/tutorial-app-generator/src/index.ts index c3cb38ae06..0000c6050e 100644 --- a/web/tutorial-app-generator/src/index.ts +++ b/web/tutorial-app-generator/src/index.ts @@ -1,5 +1,3 @@ -import path from "path"; - import { $ } from "zx"; import { program } from "@commander-js/extra-typings"; @@ -8,39 +6,24 @@ import { getActionsFromTutorialFiles } from "./markdown/extractSteps"; import { appDir } from "./paths"; import { waspNew } from "./waspCli"; -import { updateBrokenDiffs } from "./edit"; import { executeSteps } from "./execute-steps"; const actions: Action[] = await getActionsFromTutorialFiles(); -const { brokenDiff, untilStep } = program +function findStepOrThrow(stepName: string): Action { + const action = actions.find((action) => action.step === stepName); + if (!action) { + throw new Error(`No action found for step ${stepName}.`); + } + return action; +} + +const { untilStep } = program .option( - "-s, --until-step ", + "-s, --until-step ", "Run until the given step. If not provided, run all steps.", - (value: string) => { - const step = parseInt(value, 10); - if (isNaN(step) || step < 1) { - throw new Error("Step must be a positive integer."); - } - return step; - }, - ) - .option( - "-e, --broken-diff ", - "Edit mode, you will edit the diff interactively and all the steps related to the same file that come after.", - (value: string) => { - const step = parseInt(value, 10); - if (isNaN(step) || step < 1) { - throw new Error("Step must be a positive integer."); - } - const actionAtStep = actions.find((action) => action.step === step); - if (!actionAtStep) { - throw new Error(`No action found for step ${step}.`); - } - if (actionAtStep.kind !== "diff") { - throw new Error(`Action at step ${step} is not a diff action.`); - } - return actionAtStep; + (stepName: string) => { + return findStepOrThrow(stepName); }, ) .parse(process.argv) @@ -51,20 +34,21 @@ $.verbose = true; async function prepareApp() { await $`rm -rf ${appDir}`; await waspNew(appDir); - // TODO: Maybe we should have a whitelist of files we want to keep in src? - await $`rm ${path.join(appDir, "src/Main.css")}`; - await $`rm ${path.join(appDir, "src/waspLogo.png")}`; - await $`rm ${path.join(appDir, "src/MainPage.jsx")}`; // Git needs to be initialized for patches to work - await $`cd ${appDir} && git init`; + await $`cd ${appDir} && git init && git add . && git commit -m "Initial commit"`; } await prepareApp(); -if (brokenDiff) { - await updateBrokenDiffs(brokenDiff, actions); -} else { - await executeSteps(actions, { - untilStep, - }); -} +await executeSteps(actions, { + untilStep, +}); + +// Commit -> patch +// git format-patch -1 migration-connect-task-user --stdout + +// git switch -c fixes $step4commitSHA +// git commit -a --fixup=$step4commitSHA +// git switch main +// git rebase fixes +// git rebase --root --autosquash diff --git a/web/tutorial-app-generator/src/markdown/extractSteps.ts b/web/tutorial-app-generator/src/markdown/extractSteps.ts index 21bf37306c..a3c9183204 100644 --- a/web/tutorial-app-generator/src/markdown/extractSteps.ts +++ b/web/tutorial-app-generator/src/markdown/extractSteps.ts @@ -110,9 +110,8 @@ async function getActionsFromFile(filePath: string): Promise { return actions; } -function getStep(node: MdxJsxFlowElement): number | null { - const step = getAttributeValue(node, "step"); - return step !== null ? parseInt(step, 10) : null; +function getStep(node: MdxJsxFlowElement): string | null { + return getAttributeValue(node, "step"); } function getAttributeValue( diff --git a/web/tutorial-app-generator/src/waspCli.ts b/web/tutorial-app-generator/src/waspCli.ts index 2c249ea506..cd64b6041d 100644 --- a/web/tutorial-app-generator/src/waspCli.ts +++ b/web/tutorial-app-generator/src/waspCli.ts @@ -11,5 +11,5 @@ export async function waspDbMigrate( } export async function waspNew(appDir: string): Promise { - await $`wasp new ${appDir}`; + await $`wasp new ${appDir} -t minimal`; } From 10c1f1c16e67e711491b2abb4c5f9a69a4c4f0e6 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 17 Jul 2025 12:15:50 +0200 Subject: [PATCH 09/48] Update TutorialAction component --- web/docs/tutorial/03-pages.md | 20 ++--- web/docs/tutorial/04-entities.md | 2 +- web/docs/tutorial/05-queries.md | 40 +++++---- web/docs/tutorial/06-actions.md | 124 +++++++++++++------------- web/docs/tutorial/07-auth.md | 70 +++++++-------- web/docs/tutorial/TutorialAction.tsx | 30 +++++++ web/src/components/TutorialAction.tsx | 13 --- web/tailwind.config.js | 5 +- 8 files changed, 162 insertions(+), 142 deletions(-) create mode 100644 web/docs/tutorial/TutorialAction.tsx delete mode 100644 web/src/components/TutorialAction.tsx diff --git a/web/docs/tutorial/03-pages.md b/web/docs/tutorial/03-pages.md index f0be20fc84..a47f395453 100644 --- a/web/docs/tutorial/03-pages.md +++ b/web/docs/tutorial/03-pages.md @@ -6,7 +6,7 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs } from '@site/src/components/TsJsHelpers'; import WaspStartNote from '../\_WaspStartNote.md' import TypescriptServerNote from '../\_TypescriptServerNote.md' -import { TutorialAction } from '@site/src/components/TutorialAction'; +import { TutorialAction } from './TutorialAction'; In the default `main.wasp` file created by `wasp new`, there is a **page** and a **route** declaration: @@ -43,12 +43,12 @@ Together, these declarations tell Wasp that when a user navigates to `/`, it sho Let's take a look at the React component referenced by the page declaration: ```tsx title="src/MainPage.tsx" auto-js -import waspLogo from './waspLogo.png' -import './Main.css' +import waspLogo from "./waspLogo.png"; +import "./Main.css"; export const MainPage = () => { // ... -} +}; ``` This is a regular functional React component. It also uses the CSS file and a logo image that sit next to it in the `src` folder. @@ -75,12 +75,12 @@ page HelloPage { When a user visits `/hello/their-name`, Wasp renders the component exported from `src/HelloPage.{jsx,tsx}` and you can use the `useParams` hook from `react-router-dom` to access the `name` parameter: ```tsx title="src/HelloPage.tsx" auto-js -import { useParams } from 'react-router-dom' +import { useParams } from "react-router-dom"; export const HelloPage = () => { - const { name } = useParams<'name'>() - return
Here's {name}!
-} + const { name } = useParams<"name">(); + return
Here's {name}!
; +}; ``` Now you can visit `/hello/johnny` and see "Here's johnny!" @@ -105,8 +105,8 @@ First, remove most of the code from the `MainPage` component: ```tsx title="src/MainPage.tsx" auto-js export const MainPage = () => { - return
Hello world!
-} + return
Hello world!
; +}; ```
diff --git a/web/docs/tutorial/04-entities.md b/web/docs/tutorial/04-entities.md index 1b12c80479..7679d6e424 100644 --- a/web/docs/tutorial/04-entities.md +++ b/web/docs/tutorial/04-entities.md @@ -3,7 +3,7 @@ title: 4. Database Entities --- import useBaseUrl from '@docusaurus/useBaseUrl'; -import { TutorialAction } from '@site/src/components/TutorialAction'; +import { TutorialAction } from './TutorialAction'; Entities are one of the most important concepts in Wasp and are how you define what gets stored in the database. diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index c9de60ea8e..4c132f3754 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -4,7 +4,7 @@ title: 5. Querying the Database import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers'; -import { TutorialAction } from '@site/src/components/TutorialAction'; +import { TutorialAction } from './TutorialAction'; We want to know which tasks we need to do, so let's list them! @@ -40,6 +40,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis entities: [Task] } ``` +
@@ -61,6 +62,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis :::note To generate the types used in the next section, make sure that `wasp start` is still running. ::: + @@ -77,14 +79,14 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis ```ts title="src/queries.ts" auto-js -import type { Task } from 'wasp/entities' -import type { GetTasks } from 'wasp/server/operations' +import type { Task } from "wasp/entities"; +import type { GetTasks } from "wasp/server/operations"; export const getTasks: GetTasks = async (args, context) => { return context.entities.Task.findMany({ - orderBy: { id: 'asc' }, - }) -} + orderBy: { id: "asc" }, + }); +}; ``` @@ -125,24 +127,24 @@ This makes it easy for us to use the `getTasks` Query we just created in our Rea ```tsx title="src/MainPage.tsx" auto-js -import type { Task } from 'wasp/entities' +import type { Task } from "wasp/entities"; // highlight-next-line -import { getTasks, useQuery } from 'wasp/client/operations' +import { getTasks, useQuery } from "wasp/client/operations"; export const MainPage = () => { // highlight-start - const { data: tasks, isLoading, error } = useQuery(getTasks) + const { data: tasks, isLoading, error } = useQuery(getTasks); return (
{tasks && } - {isLoading && 'Loading...'} - {error && 'Error: ' + error} + {isLoading && "Loading..."} + {error && "Error: " + error}
- ) + ); // highlight-end -} +}; // highlight-start const TaskView = ({ task }: { task: Task }) => { @@ -151,11 +153,11 @@ const TaskView = ({ task }: { task: Task }) => { {task.description} - ) -} + ); +}; const TasksList = ({ tasks }: { tasks: Task[] }) => { - if (!tasks?.length) return
No tasks
+ if (!tasks?.length) return
No tasks
; return (
@@ -163,8 +165,8 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { ))}
- ) -} + ); +}; // highlight-end ``` @@ -182,7 +184,7 @@ Most of this code is regular React, the only exception being the two< - `useQuery` - Wasp's [useQuery](../data-model/operations/queries#the-usequery-hook-1) React hook, which is based on [react-query](https://github.com/tannerlinsley/react-query)'s hook with the same name. - `Task` - The type for the Task entity defined in `schema.prisma`. - Notice how you don't need to annotate the type of the Query's return value: Wasp uses the types you defined while implementing the Query for the generated client-side function. This is **full-stack type safety**: the types on the client always match the types on the server. +Notice how you don't need to annotate the type of the Query's return value: Wasp uses the types you defined while implementing the Query for the generated client-side function. This is **full-stack type safety**: the types on the client always match the types on the server. We could have called the Query directly using `getTasks()`, but the `useQuery` hook makes it reactive: React will re-render the component every time the Query changes. Remember that Wasp automatically refreshes Queries whenever the data is modified. diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index b7828ff84f..88cf48e672 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -5,7 +5,7 @@ title: 6. Modifying Data import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers'; import Collapse from '@site/src/components/Collapse'; -import { TutorialAction } from '@site/src/components/TutorialAction'; +import { TutorialAction } from './TutorialAction'; In the previous section, you learned about using Queries to fetch data. Let's now learn about Actions so you can add and update tasks in the database. @@ -23,7 +23,6 @@ Creating an Action is very similar to creating a Query. We must first declare the Action in `main.wasp`: - ```wasp title="main.wasp" @@ -42,19 +41,19 @@ Let's now define a JavaScriptTypeScript ```ts title="src/actions.ts" auto-js -import type { Task } from 'wasp/entities' -import type { CreateTask } from 'wasp/server/operations' +import type { Task } from "wasp/entities"; +import type { CreateTask } from "wasp/server/operations"; -type CreateTaskPayload = Pick +type CreateTaskPayload = Pick; export const createTask: CreateTask = async ( args, - context + context, ) => { return context.entities.Task.create({ data: { description: args.description }, - }) -} + }); +}; ``` @@ -63,7 +62,6 @@ export const createTask: CreateTask = async ( Once again, we've annotated the Action with the `CreateTask` and `Task` types generated by Wasp. Just like with queries, defining the types on the implementation makes them available on the frontend, giving us **full-stack type safety**. - :::tip We put the function in a new file `src/actions.{js,ts}`, but we could have put it anywhere we wanted! There are no limitations here, as long as the declaration in the Wasp file imports it correctly and the file is located within `src` directory. ::: @@ -75,38 +73,38 @@ Start by defining a form for creating new tasks. ```tsx title="src/MainPage.tsx" auto-js -import type { FormEvent } from 'react' -import type { Task } from 'wasp/entities' +import type { FormEvent } from "react"; +import type { Task } from "wasp/entities"; import { // highlight-next-line createTask, getTasks, - useQuery -} from 'wasp/client/operations' + useQuery, +} from "wasp/client/operations"; // ... MainPage, TaskView, TaskList ... // highlight-start const NewTaskForm = () => { const handleSubmit = async (event: FormEvent) => { - event.preventDefault() + event.preventDefault(); try { - const target = event.target as HTMLFormElement - const description = target.description.value - target.reset() - await createTask({ description }) + const target = event.target as HTMLFormElement; + const description = target.description.value; + target.reset(); + await createTask({ description }); } catch (err: any) { - window.alert('Error: ' + err.message) + window.alert("Error: " + err.message); } - } + }; return (
- ) -} + ); +}; // highlight-end ``` @@ -121,23 +119,23 @@ All that's left now is adding this form to the page component: ```tsx title="src/MainPage.tsx" auto-js -import type { FormEvent } from 'react' -import type { Task } from 'wasp/entities' -import { createTask, getTasks, useQuery } from 'wasp/client/operations' +import type { FormEvent } from "react"; +import type { Task } from "wasp/entities"; +import { createTask, getTasks, useQuery } from "wasp/client/operations"; const MainPage = () => { - const { data: tasks, isLoading, error } = useQuery(getTasks) + const { data: tasks, isLoading, error } = useQuery(getTasks); return (
// highlight-next-line {tasks && } - {isLoading && 'Loading...'} - {error && 'Error: ' + error} + {isLoading && "Loading..."} + {error && "Error: " + error}
- ) -} + ); +}; // ... TaskList, TaskView, NewTaskForm ... ``` @@ -177,38 +175,39 @@ Since we've already created one task together, try to create this one yourself. - ```wasp title="main.wasp" - // ... +```wasp title="main.wasp" +// ... - action updateTask { - fn: import { updateTask } from "@src/actions", - entities: [Task] - } - ``` +action updateTask { + fn: import { updateTask } from "@src/actions", + entities: [Task] +} +``` - Implementing the Action on the server: +Implementing the Action on the server: - ```ts title="src/actions.ts" auto-js - import type { CreateTask, UpdateTask } from 'wasp/server/operations' +```ts title="src/actions.ts" auto-js +import type { CreateTask, UpdateTask } from "wasp/server/operations"; - // ... +// ... - type UpdateTaskPayload = Pick +type UpdateTaskPayload = Pick; + +export const updateTask: UpdateTask = async ( + { id, isDone }, + context, +) => { + return context.entities.Task.update({ + where: { id }, + data: { + isDone: isDone, + }, + }); +}; +``` - export const updateTask: UpdateTask = async ( - { id, isDone }, - context - ) => { - return context.entities.Task.update({ - where: { id }, - data: { - isDone: isDone, - }, - }) - } - ``` You can now call `updateTask` from the React component: @@ -216,16 +215,15 @@ You can now call `updateTask` from the React component: ```tsx title="src/MainPage.tsx" auto-js -import type { FormEvent, ChangeEvent } from 'react' -import type { Task } from 'wasp/entities' +import type { FormEvent, ChangeEvent } from "react"; +import type { Task } from "wasp/entities"; import { // highlight-next-line updateTask, createTask, getTasks, useQuery, -} from 'wasp/client/operations' - +} from "wasp/client/operations"; // ... MainPage ... @@ -236,11 +234,11 @@ const TaskView = ({ task }: { task: Task }) => { await updateTask({ id: task.id, isDone: event.target.checked, - }) + }); } catch (error: any) { - window.alert('Error while updating task: ' + error.message) + window.alert("Error while updating task: " + error.message); } - } + }; // highlight-end return ( @@ -254,8 +252,8 @@ const TaskView = ({ task }: { task: Task }) => { /> {task.description} - ) -} + ); +}; // ... TaskList, NewTaskForm ... ``` diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 38b4eb23b9..1aa245d62b 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -4,7 +4,7 @@ title: 7. Adding Authentication import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers'; -import { TutorialAction } from '@site/src/components/TutorialAction'; +import { TutorialAction } from './TutorialAction'; Most modern apps need a way to create and authenticate users. Wasp makes this as easy as possible with its first-class auth support. @@ -109,20 +109,20 @@ Here's the React code for the pages you've just imported: ```tsx title="src/LoginPage.tsx" auto-js -import { Link } from 'react-router-dom' -import { LoginForm } from 'wasp/client/auth' +import { Link } from "react-router-dom"; +import { LoginForm } from "wasp/client/auth"; export const LoginPage = () => { return ( -
+

I don't have an account yet (go to signup).
- ) -} + ); +}; ``` @@ -132,20 +132,20 @@ The signup page is very similar to the login page: ```tsx title="src/SignupPage.tsx" auto-js -import { Link } from 'react-router-dom' -import { SignupForm } from 'wasp/client/auth' +import { Link } from "react-router-dom"; +import { SignupForm } from "wasp/client/auth"; export const SignupPage = () => { return ( -
+

I already have an account (go to login).
- ) -} + ); +}; ``` @@ -179,13 +179,13 @@ Additionally, when `authRequired` is `true`, the page's React component will be ```tsx title="src/MainPage.tsx" auto-js -import type { AuthUser } from 'wasp/auth' +import type { AuthUser } from "wasp/auth"; // highlight-next-line export const MainPage = ({ user }: { user: AuthUser }) => { // Do something with the user // ... -} +}; ``` Ok, time to test this out. Navigate to the main page (`/`) of the app. You'll get redirected to `/login`, where you'll be asked to authenticate. @@ -256,42 +256,42 @@ Next, let's update the queries and actions to forbid access to non-authenticated ```ts title="src/queries.ts" auto-js -import type { Task } from 'wasp/entities' +import type { Task } from "wasp/entities"; // highlight-next-line -import { HttpError } from 'wasp/server' -import type { GetTasks } from 'wasp/server/operations' +import { HttpError } from "wasp/server"; +import type { GetTasks } from "wasp/server/operations"; export const getTasks: GetTasks = async (args, context) => { // highlight-start if (!context.user) { - throw new HttpError(401) + throw new HttpError(401); } // highlight-end return context.entities.Task.findMany({ // highlight-next-line where: { user: { id: context.user.id } }, - orderBy: { id: 'asc' }, - }) -} + orderBy: { id: "asc" }, + }); +}; ``` ```ts title="src/actions.ts" auto-js -import type { Task } from 'wasp/entities' +import type { Task } from "wasp/entities"; // highlight-next-line -import { HttpError } from 'wasp/server' -import type { CreateTask, UpdateTask } from 'wasp/server/operations' +import { HttpError } from "wasp/server"; +import type { CreateTask, UpdateTask } from "wasp/server/operations"; -type CreateTaskPayload = Pick +type CreateTaskPayload = Pick; export const createTask: CreateTask = async ( args, - context + context, ) => { // highlight-start if (!context.user) { - throw new HttpError(401) + throw new HttpError(401); } // highlight-end return context.entities.Task.create({ @@ -300,10 +300,10 @@ export const createTask: CreateTask = async ( // highlight-next-line user: { connect: { id: context.user.id } }, }, - }) -} + }); +}; -type UpdateTaskPayload = Pick +type UpdateTaskPayload = Pick; export const updateTask: UpdateTask< UpdateTaskPayload, @@ -311,14 +311,14 @@ export const updateTask: UpdateTask< > = async (args, context) => { // highlight-start if (!context.user) { - throw new HttpError(401) + throw new HttpError(401); } // highlight-end return context.entities.Task.updateMany({ where: { id: args.id, user: { id: context.user.id } }, data: { isDone: args.isDone }, - }) -} + }); +}; ``` :::note @@ -346,7 +346,7 @@ Last, but not least, let's add the logout functionality: ```tsx title="src/MainPage.tsx" auto-js with-hole // ... // highlight-next-line -import { logout } from 'wasp/client/auth' +import { logout } from "wasp/client/auth"; //... const MainPage = () => { @@ -357,8 +357,8 @@ const MainPage = () => { // highlight-next-line
- ) -} + ); +}; ``` This is it, we have a working authentication system, and our Todo app is multi-user! diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx new file mode 100644 index 0000000000..0a7b6f7cdd --- /dev/null +++ b/web/docs/tutorial/TutorialAction.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +type Action = "diff" | "write" | "migrate-db"; + +/* + +This component serves two purposes: +1. It provides metadata for the `tutorial-app-generator` on how to execute tutorial steps programmatically. +2. It renders tutorial steps in a visually distinct way during development so it's easier to debug. + +*/ +export function TutorialAction({ + children, + action, + step, +}: React.PropsWithChildren<{ + action: Action; + step: string; +}>) { + return process.env.NODE_ENV === "production" ? ( + children + ) : ( +
+
+ Step {step} ({action}) +
+ {action === "write" && children} +
+ ); +} diff --git a/web/src/components/TutorialAction.tsx b/web/src/components/TutorialAction.tsx deleted file mode 100644 index eb1a3b95d1..0000000000 --- a/web/src/components/TutorialAction.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; - -export function TutorialAction({ - children, - action, -}: React.PropsWithChildren<{ - action: "diff" | "write" | "migrate-db"; -}>) { - if (action === "write") { - return children; - } - return null; -} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index a31688cbe0..869632cd23 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,6 +1,9 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ["./src/**/*.{js,jsx,ts,tsx}"], + content: [ + "./src/**/*.{js,jsx,ts,tsx}", + "./docs/tutorial/**/*.{js,jsx,ts,tsx}", + ], important: true, corePlugins: { preflight: false, From d00d7db36ff2e53e486a611c488ae378da05dfe5 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 17 Jul 2025 12:41:50 +0200 Subject: [PATCH 10/48] Update TutorialAction design --- web/docs/tutorial/TutorialAction.tsx | 36 ++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index 0a7b6f7cdd..6c324f86b9 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -19,12 +19,40 @@ export function TutorialAction({ }>) { return process.env.NODE_ENV === "production" ? ( children + ) : action === "write" ? ( +
+
+ +
+ {children} +
) : ( -
-
- Step {step} ({action}) +
+ +
+ ); +} + +function TutorialActionStep({ + step, + action, +}: { + step: string; + action: Action; +}) { + return ( +
+
+ tutorial action: {action} +
+
{ + navigator.clipboard.writeText(step); + }} + > + {step}
- {action === "write" && children}
); } From 6a4a290724e265725dcb0bd39c0cb2e0ff689587 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 17 Jul 2025 13:41:46 +0200 Subject: [PATCH 11/48] Stop using write action --- web/docs/tutorial/03-pages.md | 8 --- web/docs/tutorial/05-queries.md | 8 +-- web/docs/tutorial/06-actions.md | 4 +- web/docs/tutorial/07-auth.md | 8 +-- .../patches/action-create-task-impl.patch | 19 ++++++ .../patches/action-update-task-impl.patch | 26 +++------ .../tutorial/patches/login-page-initial.patch | 20 +++++++ .../tutorial/patches/main-page-add-auth.patch | 4 +- .../patches/main-page-add-logout.patch | 6 +- .../main-page-create-task-impl-form.patch | 58 +++---------------- .../tutorial/patches/main-page-tasks.patch | 42 ++++++++++++++ .../patches/main-page-update-task.patch | 6 +- .../tutorial/patches/prepare-project.patch | 26 +++++++-- .../tutorial/patches/query-add-auth.patch | 18 ++---- .../patches/query-get-tasks-impl.patch | 14 +++++ .../tutorial/patches/query-get-tasks.patch | 9 +-- .../patches/signup-page-initial.patch | 20 +++++++ .../src/actions/index.ts | 44 ++++++-------- .../src/execute-steps/index.ts | 51 ++++++++-------- web/tutorial-app-generator/src/index.ts | 24 ++++---- .../src/markdown/extractSteps.ts | 2 +- 21 files changed, 230 insertions(+), 187 deletions(-) create mode 100644 web/docs/tutorial/patches/action-create-task-impl.patch create mode 100644 web/docs/tutorial/patches/login-page-initial.patch create mode 100644 web/docs/tutorial/patches/main-page-tasks.patch create mode 100644 web/docs/tutorial/patches/query-get-tasks-impl.patch create mode 100644 web/docs/tutorial/patches/signup-page-initial.patch diff --git a/web/docs/tutorial/03-pages.md b/web/docs/tutorial/03-pages.md index a47f395453..66228168df 100644 --- a/web/docs/tutorial/03-pages.md +++ b/web/docs/tutorial/03-pages.md @@ -101,16 +101,12 @@ Start by cleaning up the starter project and removing unnecessary code and files First, remove most of the code from the `MainPage` component: - - ```tsx title="src/MainPage.tsx" auto-js export const MainPage = () => { return
Hello world!
; }; ``` -
- At this point, the main page should look like this: Todo App - Hello World @@ -121,8 +117,6 @@ Since `src/HelloPage.{jsx,tsx}` no longer exists, remove its `route` and `page` Your Wasp file should now look like this: - - ```wasp title="main.wasp" app TodoApp { wasp: { @@ -137,8 +131,6 @@ page MainPage { } ``` - - Excellent work! You now have a basic understanding of Wasp and are ready to start building your TodoApp. diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index 4c132f3754..9201a5c706 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -76,7 +76,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis Next, create a new file called `src/queries.ts` and define the TypeScript function we've just imported in our `query` declaration: - + ```ts title="src/queries.ts" auto-js import type { Task } from "wasp/entities"; @@ -89,8 +89,6 @@ export const getTasks: GetTasks = async (args, context) => { }; ``` - - Wasp automatically generates the types `GetTasks` and `Task` based on the contents of `main.wasp`: @@ -124,7 +122,7 @@ While we implement Queries on the server, Wasp generates client-side functions t This makes it easy for us to use the `getTasks` Query we just created in our React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { Task } from "wasp/entities"; @@ -170,8 +168,6 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { // highlight-end ``` - - Most of this code is regular React, the only exception being the twothree special `wasp` imports: diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index 88cf48e672..79ad5382e1 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -38,7 +38,7 @@ action createTask { Let's now define a JavaScriptTypeScript function for our `createTask` Action: - + ```ts title="src/actions.ts" auto-js import type { Task } from "wasp/entities"; @@ -56,8 +56,6 @@ export const createTask: CreateTask = async ( }; ``` - - Once again, we've annotated the Action with the `CreateTask` and `Task` types generated by Wasp. Just like with queries, defining the types on the implementation makes them available on the frontend, giving us **full-stack type safety**. diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 1aa245d62b..696cdc688e 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -106,7 +106,7 @@ Great, Wasp now knows these pages exist! Here's the React code for the pages you've just imported: - + ```tsx title="src/LoginPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -125,11 +125,9 @@ export const LoginPage = () => { }; ``` - - The signup page is very similar to the login page: - + ```tsx title="src/SignupPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -148,8 +146,6 @@ export const SignupPage = () => { }; ``` - - :::tip Type-safe links Since you are using Typescript, you can benefit from using Wasp's type-safe `Link` component and the `routes` object. Check out the [type-safe links docs](../advanced/links) for more details. diff --git a/web/docs/tutorial/patches/action-create-task-impl.patch b/web/docs/tutorial/patches/action-create-task-impl.patch new file mode 100644 index 0000000000..f28f25bc5d --- /dev/null +++ b/web/docs/tutorial/patches/action-create-task-impl.patch @@ -0,0 +1,19 @@ +diff --git a/src/actions.ts b/src/actions.ts +new file mode 100644 +index 0000000..b8b2026 +--- /dev/null ++++ b/src/actions.ts +@@ -0,0 +1,13 @@ ++import type { Task } from "wasp/entities"; ++import type { CreateTask } from "wasp/server/operations"; ++ ++type CreateTaskPayload = Pick; ++ ++export const createTask: CreateTask = async ( ++ args, ++ context, ++) => { ++ return context.entities.Task.create({ ++ data: { description: args.description }, ++ }); ++}; diff --git a/web/docs/tutorial/patches/action-update-task-impl.patch b/web/docs/tutorial/patches/action-update-task-impl.patch index f148d40c8e..96085a3d01 100644 --- a/web/docs/tutorial/patches/action-update-task-impl.patch +++ b/web/docs/tutorial/patches/action-update-task-impl.patch @@ -1,28 +1,18 @@ diff --git a/src/actions.ts b/src/actions.ts -index a7c1b28..973d821 100644 +index b8b2026..973d821 100644 --- a/src/actions.ts +++ b/src/actions.ts -@@ -1,13 +1,27 @@ --import type { Task } from 'wasp/entities' --import type { CreateTask } from 'wasp/server/operations' -+import type { Task } from "wasp/entities"; +@@ -1,5 +1,5 @@ + import type { Task } from "wasp/entities"; +-import type { CreateTask } from "wasp/server/operations"; +import type { CreateTask, UpdateTask } from "wasp/server/operations"; --type CreateTaskPayload = Pick -+type CreateTaskPayload = Pick; + type CreateTaskPayload = Pick; - export const createTask: CreateTask = async ( - args, -- context -+ context, - ) => { - return context.entities.Task.create({ +@@ -11,3 +11,17 @@ export const createTask: CreateTask = async ( data: { description: args.description }, -- }) --} -\ No newline at end of file -+ }); -+}; + }); + }; + +type UpdateTaskPayload = Pick; + diff --git a/web/docs/tutorial/patches/login-page-initial.patch b/web/docs/tutorial/patches/login-page-initial.patch new file mode 100644 index 0000000000..10251db19d --- /dev/null +++ b/web/docs/tutorial/patches/login-page-initial.patch @@ -0,0 +1,20 @@ +diff --git a/src/LoginPage.tsx b/src/LoginPage.tsx +new file mode 100644 +index 0000000..e2bfb6d +--- /dev/null ++++ b/src/LoginPage.tsx +@@ -0,0 +1,14 @@ ++import { Link } from "react-router-dom"; ++import { LoginForm } from "wasp/client/auth"; ++ ++export const LoginPage = () => { ++ return ( ++
++ ++
++ ++ I don't have an account yet (go to signup). ++ ++
++ ); ++}; diff --git a/web/docs/tutorial/patches/main-page-add-auth.patch b/web/docs/tutorial/patches/main-page-add-auth.patch index 34322ef103..301f9f5fda 100644 --- a/web/docs/tutorial/patches/main-page-add-auth.patch +++ b/web/docs/tutorial/patches/main-page-add-auth.patch @@ -1,5 +1,5 @@ diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 6c618ef..decefb6 100644 +index ec559d7..bc435c6 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx @@ -1,4 +1,5 @@ @@ -14,6 +14,6 @@ index 6c618ef..decefb6 100644 -export const MainPage = () => { +export const MainPage = ({ user }: { user: AuthUser }) => { - // highlight-start const { data: tasks, isLoading, error } = useQuery(getTasks); + return ( diff --git a/web/docs/tutorial/patches/main-page-add-logout.patch b/web/docs/tutorial/patches/main-page-add-logout.patch index 73a9282b15..c498325864 100644 --- a/web/docs/tutorial/patches/main-page-add-logout.patch +++ b/web/docs/tutorial/patches/main-page-add-logout.patch @@ -1,5 +1,5 @@ diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index decefb6..ddc6fde 100644 +index bc435c6..b93386a 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx @@ -1,5 +1,6 @@ @@ -9,11 +9,11 @@ index decefb6..ddc6fde 100644 import { createTask, getTasks, -@@ -19,6 +20,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { +@@ -18,6 +19,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { {isLoading && "Loading..."} {error && "Error: " + error} +
); - // highlight-end + }; diff --git a/web/docs/tutorial/patches/main-page-create-task-impl-form.patch b/web/docs/tutorial/patches/main-page-create-task-impl-form.patch index e74cc8d324..af1e804b0d 100644 --- a/web/docs/tutorial/patches/main-page-create-task-impl-form.patch +++ b/web/docs/tutorial/patches/main-page-create-task-impl-form.patch @@ -1,62 +1,18 @@ diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index 556e1a2..eb804b9 100644 +index addc7ba..3f1732f 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx -@@ -1,21 +1,21 @@ --import type { Task } from 'wasp/entities' --// highlight-next-line --import { getTasks, useQuery } from 'wasp/client/operations' +@@ -1,4 +1,5 @@ +-import { getTasks, useQuery } from "wasp/client/operations"; +import type { FormEvent } from "react"; +import { createTask, getTasks, useQuery } from "wasp/client/operations"; -+import type { Task } from "wasp/entities"; + import type { Task } from "wasp/entities"; export const MainPage = () => { - // highlight-start -- const { data: tasks, isLoading, error } = useQuery(getTasks) -+ const { data: tasks, isLoading, error } = useQuery(getTasks); - - return ( -
- {tasks && } - -- {isLoading && 'Loading...'} -- {error && 'Error: ' + error} -+ {isLoading && "Loading..."} -+ {error && "Error: " + error} +@@ -34,3 +35,24 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => {
-- ) -+ ); - // highlight-end --} -+}; - - // highlight-start - const TaskView = ({ task }: { task: Task }) => { -@@ -24,11 +24,11 @@ const TaskView = ({ task }: { task: Task }) => { - - {task.description} -
-- ) --} -+ ); -+}; - - const TasksList = ({ tasks }: { tasks: Task[] }) => { -- if (!tasks?.length) return
No tasks
-+ if (!tasks?.length) return
No tasks
; - - return ( -
-@@ -36,6 +36,26 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { - - ))} -
-- ) --} --// highlight-end -\ No newline at end of file -+ ); -+}; + ); + }; + +const NewTaskForm = () => { + const handleSubmit = async (event: FormEvent) => { diff --git a/web/docs/tutorial/patches/main-page-tasks.patch b/web/docs/tutorial/patches/main-page-tasks.patch new file mode 100644 index 0000000000..7bd4028566 --- /dev/null +++ b/web/docs/tutorial/patches/main-page-tasks.patch @@ -0,0 +1,42 @@ +diff --git a/src/MainPage.tsx b/src/MainPage.tsx +index 144163a..addc7ba 100644 +--- a/src/MainPage.tsx ++++ b/src/MainPage.tsx +@@ -1,3 +1,36 @@ ++import { getTasks, useQuery } from "wasp/client/operations"; ++import type { Task } from "wasp/entities"; ++ + export const MainPage = () => { +- return
Hello world!
; ++ const { data: tasks, isLoading, error } = useQuery(getTasks); ++ ++ return ( ++
++ {tasks && } ++ ++ {isLoading && "Loading..."} ++ {error && "Error: " + error} ++
++ ); ++}; ++ ++const TaskView = ({ task }: { task: Task }) => { ++ return ( ++
++ ++ {task.description} ++
++ ); ++}; ++ ++const TasksList = ({ tasks }: { tasks: Task[] }) => { ++ if (!tasks?.length) return
No tasks
; ++ ++ return ( ++
++ {tasks.map((task, idx) => ( ++ ++ ))} ++
++ ); + }; diff --git a/web/docs/tutorial/patches/main-page-update-task.patch b/web/docs/tutorial/patches/main-page-update-task.patch index 5b9f5daad1..de0210db83 100644 --- a/web/docs/tutorial/patches/main-page-update-task.patch +++ b/web/docs/tutorial/patches/main-page-update-task.patch @@ -1,5 +1,5 @@ diff --git a/src/MainPage.tsx b/src/MainPage.tsx -index cd11965..6c618ef 100644 +index 44c52c8..ec559d7 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx @@ -1,5 +1,10 @@ @@ -15,9 +15,9 @@ index cd11965..6c618ef 100644 import type { Task } from "wasp/entities"; export const MainPage = () => { -@@ -20,9 +25,25 @@ export const MainPage = () => { +@@ -17,9 +22,25 @@ export const MainPage = () => { + }; - // highlight-start const TaskView = ({ task }: { task: Task }) => { + const handleIsDoneChange = async (event: ChangeEvent) => { + try { diff --git a/web/docs/tutorial/patches/prepare-project.patch b/web/docs/tutorial/patches/prepare-project.patch index 4bbd5e6f16..c12bcdfbdb 100644 --- a/web/docs/tutorial/patches/prepare-project.patch +++ b/web/docs/tutorial/patches/prepare-project.patch @@ -1,3 +1,19 @@ +diff --git a/main.wasp b/main.wasp +index 1b1bd08..a5f1899 100644 +--- a/main.wasp ++++ b/main.wasp +@@ -2,10 +2,7 @@ app TodoApp { + wasp: { + version: "^0.17.0" + }, +- title: "TodoApp", +- head: [ +- "", +- ] ++ title: "TodoApp" + } + + route RootRoute { path: "/", to: MainPage } diff --git a/src/Main.css b/src/Main.css deleted file mode 100644 index 9e93c7a..0000000 @@ -108,11 +124,10 @@ index 9e93c7a..0000000 - monospace; -} diff --git a/src/MainPage.tsx b/src/MainPage.tsx -deleted file mode 100644 -index bdb62b3..0000000 +index bdb62b3..144163a 100644 --- a/src/MainPage.tsx -+++ /dev/null -@@ -1,37 +0,0 @@ ++++ b/src/MainPage.tsx +@@ -1,37 +1,3 @@ -import Logo from "./assets/logo.svg"; -import "./Main.css"; - @@ -150,6 +165,9 @@ index bdb62b3..0000000 - - ); -} ++export const MainPage = () => { ++ return
Hello world!
; ++}; diff --git a/src/assets/logo.svg b/src/assets/logo.svg deleted file mode 100644 index faa99ae..0000000 diff --git a/web/docs/tutorial/patches/query-add-auth.patch b/web/docs/tutorial/patches/query-add-auth.patch index 8bb0c00d36..58918dcded 100644 --- a/web/docs/tutorial/patches/query-add-auth.patch +++ b/web/docs/tutorial/patches/query-add-auth.patch @@ -1,24 +1,18 @@ diff --git a/src/queries.ts b/src/queries.ts -index 1738f22..92a513e 100644 +index 4e4ba1b..92a513e 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -1,8 +1,13 @@ --import type { Task } from 'wasp/entities' --import type { GetTasks } from 'wasp/server/operations' -+import type { Task } from "wasp/entities"; + import type { Task } from "wasp/entities"; +import { HttpError } from "wasp/server"; -+import type { GetTasks } from "wasp/server/operations"; + import type { GetTasks } from "wasp/server/operations"; export const getTasks: GetTasks = async (args, context) => { + if (!context.user) { + throw new HttpError(401); + } return context.entities.Task.findMany({ -- orderBy: { id: 'asc' }, -- }) --} -\ No newline at end of file + where: { user: { id: context.user.id } }, -+ orderBy: { id: "asc" }, -+ }); -+}; + orderBy: { id: "asc" }, + }); + }; diff --git a/web/docs/tutorial/patches/query-get-tasks-impl.patch b/web/docs/tutorial/patches/query-get-tasks-impl.patch new file mode 100644 index 0000000000..6b0c04357c --- /dev/null +++ b/web/docs/tutorial/patches/query-get-tasks-impl.patch @@ -0,0 +1,14 @@ +diff --git a/src/queries.ts b/src/queries.ts +new file mode 100644 +index 0000000..4e4ba1b +--- /dev/null ++++ b/src/queries.ts +@@ -0,0 +1,8 @@ ++import type { Task } from "wasp/entities"; ++import type { GetTasks } from "wasp/server/operations"; ++ ++export const getTasks: GetTasks = async (args, context) => { ++ return context.entities.Task.findMany({ ++ orderBy: { id: "asc" }, ++ }); ++}; diff --git a/web/docs/tutorial/patches/query-get-tasks.patch b/web/docs/tutorial/patches/query-get-tasks.patch index f8727ede55..9248897f55 100644 --- a/web/docs/tutorial/patches/query-get-tasks.patch +++ b/web/docs/tutorial/patches/query-get-tasks.patch @@ -1,14 +1,11 @@ diff --git a/main.wasp b/main.wasp -index 6b4593c..c3df1d1 100644 +index a5f1899..c3df1d1 100644 --- a/main.wasp +++ b/main.wasp -@@ -8,4 +8,14 @@ app TodoApp { - route RootRoute { path: "/", to: MainPage } +@@ -9,3 +9,13 @@ route RootRoute { path: "/", to: MainPage } page MainPage { component: import { MainPage } from "@src/MainPage" --} -\ No newline at end of file -+} + } + +query getTasks { + // Specifies where the implementation for the query function is. diff --git a/web/docs/tutorial/patches/signup-page-initial.patch b/web/docs/tutorial/patches/signup-page-initial.patch new file mode 100644 index 0000000000..8f87fcf87c --- /dev/null +++ b/web/docs/tutorial/patches/signup-page-initial.patch @@ -0,0 +1,20 @@ +diff --git a/src/SignupPage.tsx b/src/SignupPage.tsx +new file mode 100644 +index 0000000..ee62723 +--- /dev/null ++++ b/src/SignupPage.tsx +@@ -0,0 +1,14 @@ ++import { Link } from "react-router-dom"; ++import { SignupForm } from "wasp/client/auth"; ++ ++export const SignupPage = () => { ++ return ( ++
++ ++
++ ++ I already have an account (go to login). ++ ++
++ ); ++}; diff --git a/web/tutorial-app-generator/src/actions/index.ts b/web/tutorial-app-generator/src/actions/index.ts index deb0729217..f1320139c2 100644 --- a/web/tutorial-app-generator/src/actions/index.ts +++ b/web/tutorial-app-generator/src/actions/index.ts @@ -9,16 +9,10 @@ import { log } from "../log"; import { waspDbMigrate } from "../waspCli"; export type ActionCommon = { - step: string; + stepName: string; markdownSourceFilePath: string; }; -export type WriteFileAction = { - kind: "write"; - path: string; - content: string; -} & ActionCommon; - export type ApplyPatchAction = { kind: "diff"; patchContentPath: string; @@ -28,13 +22,7 @@ export type MigrateDbAction = { kind: "migrate-db"; } & ActionCommon; -export type Action = WriteFileAction | ApplyPatchAction | MigrateDbAction; - -export async function writeFile(appDir: string, action: WriteFileAction) { - const filePath = path.resolve(appDir, action.path); - await fs.writeFile(filePath, action.content); - log("info", `Wrote to ${action.path}`); -} +export type Action = ApplyPatchAction | MigrateDbAction; export async function ensurePatchExists( appDir: string, @@ -45,13 +33,13 @@ export async function ensurePatchExists( await Enquirer.prompt({ type: "confirm", name: "edit", - message: `Apply edit for ${action.step} and press Enter`, + message: `Apply edit for ${action.stepName} and press Enter`, initial: true, }); - const patch = await generateGitPatch(appDir); + await commitStep(appDir, action.stepName); + const patch = await generateGitPatch(appDir, action.stepName); await fs.writeFile(action.patchContentPath, patch, "utf-8"); log("info", `Patch file created: ${action.patchContentPath}`); - await undoChanges(appDir); } assertValidPatchFile(action.patchContentPath); } @@ -60,20 +48,22 @@ function undoChanges(appDir: string) { return $`cd ${appDir} && git reset --hard HEAD && git clean -fd`.quiet(true); } -export async function generateGitPatch(appDir: string): Promise { - const { stdout: patch } = await $`cd ${appDir} && git diff`.verbose(false); +export async function generateGitPatch( + appDir: string, + stepName: string, +): Promise { + const { stdout: patch } = + await $`cd ${appDir} && git show --format= ${stepName}`.verbose(false); return patch; } -export async function commitStep(appDir: string, action: ActionCommon) { - await $`cd ${appDir} && git add . && git commit -m "${action.step}" && git tag ${action.step}`; - log("info", `Committed step ${action.step}`); +export async function commitStep(appDir: string, stepName: string) { + await $`cd ${appDir} && git add . && git commit -m "${stepName}" && git tag ${stepName}`; + log("info", `Committed step ${stepName}`); } -export async function applyPatch(appDir: string, action: ApplyPatchAction) { - await $`cd ${appDir} && git apply ${action.patchContentPath} --verbose`.quiet( - true, - ); +export async function applyPatch(appDir: string, patchContentPath: string) { + await $`cd ${appDir} && git apply ${patchContentPath} --verbose`.quiet(true); } export const migrateDb = waspDbMigrate; @@ -84,7 +74,7 @@ export function createApplyPatchAction( const patchContentPath = path.resolve( "../docs/tutorial", "patches", - `${commonActionData.step}.patch`, + `${commonActionData.stepName}.patch`, ); return { diff --git a/web/tutorial-app-generator/src/execute-steps/index.ts b/web/tutorial-app-generator/src/execute-steps/index.ts index 41c9a81ea3..1cb7384bce 100644 --- a/web/tutorial-app-generator/src/execute-steps/index.ts +++ b/web/tutorial-app-generator/src/execute-steps/index.ts @@ -1,31 +1,22 @@ +import fs from "fs/promises"; + import { chalk } from "zx"; + import { applyPatch, commitStep, ensurePatchExists, migrateDb, - writeFile, type Action, + type ApplyPatchAction, } from "../actions"; import { log } from "../log"; import { appDir, ensureDirExists, patchesDir } from "../paths"; -export async function executeSteps( - actions: Action[], - { - untilStep, - }: { - untilStep?: Action; - }, -): Promise { +export async function executeSteps(actions: Action[]): Promise { for (const action of actions) { - if (untilStep && action.step === untilStep.step) { - log("info", `Stopping before step ${action.step}`); - process.exit(0); - } - const kind = action.kind; - log("info", `${chalk.bold(`[step ${action.step}]`)} ${kind}`); + log("info", `${chalk.bold(`[step ${action.stepName}]`)} ${kind}`); // Prepare the patches directory await ensureDirExists(patchesDir); @@ -33,22 +24,36 @@ export async function executeSteps( try { switch (kind) { case "diff": - await ensurePatchExists(appDir, action); - await applyPatch(appDir, action); - break; - case "write": - await writeFile(appDir, action); + try { + await applyPatch(appDir, action.patchContentPath); + await commitStep(appDir, action.stepName); + } catch (err) { + log( + "error", + `Failed to apply patch for step ${action.stepName}:\n${err}`, + ); + await tryToFixPatch(appDir, action); + } break; case "migrate-db": - await migrateDb(appDir, `step-${action.step}`); + await migrateDb(appDir, `step-${action.stepName}`); + await commitStep(appDir, action.stepName); break; default: kind satisfies never; } } catch (err) { - log("error", `Error in step ${action.step}:\n\n${err}`); + log("error", `Error in step ${action.stepName}:\n\n${err}`); process.exit(1); } - await commitStep(appDir, action); } } + +async function tryToFixPatch( + appDir: string, + action: ApplyPatchAction, +): Promise { + log("info", `Trying to fix patch ${action.patchContentPath}...`); + await fs.unlink(action.patchContentPath).catch(() => {}); + await ensurePatchExists(appDir, action); +} diff --git a/web/tutorial-app-generator/src/index.ts b/web/tutorial-app-generator/src/index.ts index 0000c6050e..c2eb73d6fe 100644 --- a/web/tutorial-app-generator/src/index.ts +++ b/web/tutorial-app-generator/src/index.ts @@ -11,23 +11,14 @@ import { executeSteps } from "./execute-steps"; const actions: Action[] = await getActionsFromTutorialFiles(); function findStepOrThrow(stepName: string): Action { - const action = actions.find((action) => action.step === stepName); + const action = actions.find((action) => action.stepName === stepName); if (!action) { throw new Error(`No action found for step ${stepName}.`); } return action; } -const { untilStep } = program - .option( - "-s, --until-step ", - "Run until the given step. If not provided, run all steps.", - (stepName: string) => { - return findStepOrThrow(stepName); - }, - ) - .parse(process.argv) - .opts(); +const _args = program.parse(process.argv).opts(); $.verbose = true; @@ -40,9 +31,7 @@ async function prepareApp() { await prepareApp(); -await executeSteps(actions, { - untilStep, -}); +await executeSteps(actions); // Commit -> patch // git format-patch -1 migration-connect-task-user --stdout @@ -52,3 +41,10 @@ await executeSteps(actions, { // git switch main // git rebase fixes // git rebase --root --autosquash + +// You can't just change stuff... you need to have the old commits +// ready first - then you execute "change step" and it will regenerate +// the patches for you. + +// Do we just use Git for this workflow? Or we wrap the Git workflow +// in this tutorial app generator CLI? diff --git a/web/tutorial-app-generator/src/markdown/extractSteps.ts b/web/tutorial-app-generator/src/markdown/extractSteps.ts index a3c9183204..cb19cb577c 100644 --- a/web/tutorial-app-generator/src/markdown/extractSteps.ts +++ b/web/tutorial-app-generator/src/markdown/extractSteps.ts @@ -64,7 +64,7 @@ async function getActionsFromFile(filePath: string): Promise { } const commonActionData: ActionCommon = { - step, + stepName: step, markdownSourceFilePath: filePath, }; From 0a2f90fee764063adfbcc67ce362bab27d3ca276 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 17 Jul 2025 13:55:16 +0200 Subject: [PATCH 12/48] Cleanup --- .../src/actions/index.ts | 52 ++++++++++++------- .../src/execute-steps/index.ts | 17 +----- .../src/markdown/extractSteps.ts | 36 ++----------- 3 files changed, 39 insertions(+), 66 deletions(-) diff --git a/web/tutorial-app-generator/src/actions/index.ts b/web/tutorial-app-generator/src/actions/index.ts index f1320139c2..6c1992e2a3 100644 --- a/web/tutorial-app-generator/src/actions/index.ts +++ b/web/tutorial-app-generator/src/actions/index.ts @@ -24,36 +24,48 @@ export type MigrateDbAction = { export type Action = ApplyPatchAction | MigrateDbAction; -export async function ensurePatchExists( +export async function tryToFixPatch( appDir: string, action: ApplyPatchAction, -) { +): Promise { + log("info", `Trying to fix patch for step: ${action.stepName}`); + const patchPath = path.resolve(appDir, action.patchContentPath); - if (!(await fs.stat(patchPath).catch(() => false))) { - await Enquirer.prompt({ - type: "confirm", - name: "edit", - message: `Apply edit for ${action.stepName} and press Enter`, - initial: true, - }); - await commitStep(appDir, action.stepName); - const patch = await generateGitPatch(appDir, action.stepName); - await fs.writeFile(action.patchContentPath, patch, "utf-8"); - log("info", `Patch file created: ${action.patchContentPath}`); + if (await fs.stat(patchPath).catch(() => false)) { + log("info", `Removing existing patch file: ${patchPath}`); + await fs.unlink(patchPath); } - assertValidPatchFile(action.patchContentPath); + + await createPatchForStep(appDir, action); } -function undoChanges(appDir: string) { - return $`cd ${appDir} && git reset --hard HEAD && git clean -fd`.quiet(true); +export async function createPatchForStep( + appDir: string, + action: ApplyPatchAction, +) { + await Enquirer.prompt({ + type: "confirm", + name: "edit", + message: `Apply edit for ${action.stepName} and press Enter`, + initial: true, + }); + const patch = await generatePatchFromChanges(appDir); + await fs.writeFile(action.patchContentPath, patch, "utf-8"); + log("info", `Patch file created: ${action.patchContentPath}`); + + assertValidPatchFile(action.patchContentPath); } -export async function generateGitPatch( +export async function generatePatchFromChanges( appDir: string, - stepName: string, ): Promise { - const { stdout: patch } = - await $`cd ${appDir} && git show --format= ${stepName}`.verbose(false); + const temporaryTagName = "temporary-patch-tag"; + const { stdout: patch } = await $`cd ${appDir} && + git add . && + git commit -m "${temporaryTagName}" && + git show --format= ${temporaryTagName} + git reset --hard HEAD~1 + `.verbose(false); return patch; } diff --git a/web/tutorial-app-generator/src/execute-steps/index.ts b/web/tutorial-app-generator/src/execute-steps/index.ts index 1cb7384bce..9261fe468e 100644 --- a/web/tutorial-app-generator/src/execute-steps/index.ts +++ b/web/tutorial-app-generator/src/execute-steps/index.ts @@ -1,14 +1,11 @@ -import fs from "fs/promises"; - import { chalk } from "zx"; import { applyPatch, commitStep, - ensurePatchExists, migrateDb, + tryToFixPatch, type Action, - type ApplyPatchAction, } from "../actions"; import { log } from "../log"; import { appDir, ensureDirExists, patchesDir } from "../paths"; @@ -26,7 +23,6 @@ export async function executeSteps(actions: Action[]): Promise { case "diff": try { await applyPatch(appDir, action.patchContentPath); - await commitStep(appDir, action.stepName); } catch (err) { log( "error", @@ -37,7 +33,6 @@ export async function executeSteps(actions: Action[]): Promise { break; case "migrate-db": await migrateDb(appDir, `step-${action.stepName}`); - await commitStep(appDir, action.stepName); break; default: kind satisfies never; @@ -46,14 +41,6 @@ export async function executeSteps(actions: Action[]): Promise { log("error", `Error in step ${action.stepName}:\n\n${err}`); process.exit(1); } + await commitStep(appDir, action.stepName); } } - -async function tryToFixPatch( - appDir: string, - action: ApplyPatchAction, -): Promise { - log("info", `Trying to fix patch ${action.patchContentPath}...`); - await fs.unlink(action.patchContentPath).catch(() => {}); - await ensurePatchExists(appDir, action); -} diff --git a/web/tutorial-app-generator/src/markdown/extractSteps.ts b/web/tutorial-app-generator/src/markdown/extractSteps.ts index cb19cb577c..436f4f81f1 100644 --- a/web/tutorial-app-generator/src/markdown/extractSteps.ts +++ b/web/tutorial-app-generator/src/markdown/extractSteps.ts @@ -56,7 +56,7 @@ async function getActionsFromFile(filePath: string): Promise { if (node.name !== componentName) { return; } - const step = getStep(node); + const step = getAttributeValue(node, "step"); const action = getAttributeValue(node, "action"); if (!step || !action) { @@ -74,46 +74,20 @@ async function getActionsFromFile(filePath: string): Promise { kind: "migrate-db", }); return; - } - - if (action === "diff") { + } else if (action === "diff") { const patchAction = createApplyPatchAction(commonActionData); actions.push(patchAction); return; } - if (node.children.length !== 1) { - throw new Error(`${componentName} must have exactly one child`); - } - - const childCode = node.children[0]; - if (childCode === undefined || childCode.type !== "code") { - throw new Error(`${componentName} must have a code child`); - } - - const codeBlockCode = childCode.value; - - if (action === "write") { - const path = getAttributeValue(node, "path"); - if (!path) { - throw new Error("Path attribute is required for write action"); - } - actions.push({ - ...commonActionData, - kind: "write", - content: codeBlockCode, - path, - }); - } + throw new Error( + `Unknown action type: ${action} in file: ${filePath}, step: ${step}`, + ); }); return actions; } -function getStep(node: MdxJsxFlowElement): string | null { - return getAttributeValue(node, "step"); -} - function getAttributeValue( node: MdxJsxFlowElement, attributeName: string, From 3ce2ca357ce2b15b5afc3ce8628ee19dc7b740b6 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 17 Jul 2025 14:54:37 +0200 Subject: [PATCH 13/48] Cleanup Signed-off-by: Mihovil Ilakovac --- web/scripts/generate-llm-files.mts | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 web/scripts/generate-llm-files.mts diff --git a/web/scripts/generate-llm-files.mts b/web/scripts/generate-llm-files.mts deleted file mode 100644 index e69de29bb2..0000000000 From de1ad8a6ba2f7ba69be268a56fdf9910878c7655 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 17 Jul 2025 15:10:55 +0200 Subject: [PATCH 14/48] Cleanup --- web/docs/tutorial/03-pages.md | 2 +- web/docs/tutorial/04-entities.md | 2 +- web/docs/tutorial/05-queries.md | 6 +- web/docs/tutorial/06-actions.md | 14 +-- web/docs/tutorial/07-auth.md | 22 ++--- web/docs/tutorial/TutorialAction.tsx | 2 +- .../src/actions/index.ts | 97 ------------------- web/tutorial-app-generator/src/diff.ts | 13 --- .../src/executeSteps/actions.ts | 33 +++++++ .../{execute-steps => executeSteps}/index.ts | 18 ++-- .../src/executeSteps/patch.ts | 54 +++++++++++ .../customHeadingId.ts | 0 .../extractSteps.ts => extractSteps/index.ts} | 4 +- web/tutorial-app-generator/src/git.ts | 22 +++++ web/tutorial-app-generator/src/index.ts | 23 +---- 15 files changed, 145 insertions(+), 167 deletions(-) delete mode 100644 web/tutorial-app-generator/src/actions/index.ts delete mode 100644 web/tutorial-app-generator/src/diff.ts create mode 100644 web/tutorial-app-generator/src/executeSteps/actions.ts rename web/tutorial-app-generator/src/{execute-steps => executeSteps}/index.ts (73%) create mode 100644 web/tutorial-app-generator/src/executeSteps/patch.ts rename web/tutorial-app-generator/src/{markdown => extractSteps}/customHeadingId.ts (100%) rename web/tutorial-app-generator/src/{markdown/extractSteps.ts => extractSteps/index.ts} (97%) create mode 100644 web/tutorial-app-generator/src/git.ts diff --git a/web/docs/tutorial/03-pages.md b/web/docs/tutorial/03-pages.md index 66228168df..4e5e1a86c0 100644 --- a/web/docs/tutorial/03-pages.md +++ b/web/docs/tutorial/03-pages.md @@ -97,7 +97,7 @@ Now that you've seen how Wasp deals with Routes and Pages, it's finally time to Start by cleaning up the starter project and removing unnecessary code and files. - + First, remove most of the code from the `MainPage` component: diff --git a/web/docs/tutorial/04-entities.md b/web/docs/tutorial/04-entities.md index 7679d6e424..58506b43fe 100644 --- a/web/docs/tutorial/04-entities.md +++ b/web/docs/tutorial/04-entities.md @@ -11,7 +11,7 @@ Wasp uses Prisma to talk to the database, and you define Entities by defining Pr Since our Todo app is all about tasks, we'll define a Task entity by adding a Task model in the `schema.prisma` file: - + ```prisma title="schema.prisma" // ... diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index 9201a5c706..1b82392eab 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -44,7 +44,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis - + ```wasp title="main.wasp" // ... @@ -76,7 +76,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis Next, create a new file called `src/queries.ts` and define the TypeScript function we've just imported in our `query` declaration: - + ```ts title="src/queries.ts" auto-js import type { Task } from "wasp/entities"; @@ -122,7 +122,7 @@ While we implement Queries on the server, Wasp generates client-side functions t This makes it easy for us to use the `getTasks` Query we just created in our React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { Task } from "wasp/entities"; diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index 79ad5382e1..c7b2cf426e 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -23,7 +23,7 @@ Creating an Action is very similar to creating a Query. We must first declare the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -38,7 +38,7 @@ action createTask { Let's now define a JavaScriptTypeScript function for our `createTask` Action: - + ```ts title="src/actions.ts" auto-js import type { Task } from "wasp/entities"; @@ -68,7 +68,7 @@ We put the function in a new file `src/actions.{js,ts}`, but we could have put i Start by defining a form for creating new tasks. - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from "react"; @@ -114,7 +114,7 @@ Unlike Queries, you can call Actions directly (without wrapping them in a hook) All that's left now is adding this form to the page component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from "react"; @@ -171,7 +171,7 @@ Since we've already created one task together, try to create this one yourself. Declaring the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -182,7 +182,7 @@ action updateTask { } ``` - + Implementing the Action on the server: @@ -210,7 +210,7 @@ export const updateTask: UpdateTask = async ( You can now call `updateTask` from the React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent, ChangeEvent } from "react"; diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 696cdc688e..38700cdea9 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -24,7 +24,7 @@ Since Wasp manages authentication, it will create [the auth related entities](.. You must only add the `User` Entity to keep track of who owns which tasks: - + ```prisma title="schema.prisma" // ... @@ -38,7 +38,7 @@ model User { Next, tell Wasp to use full-stack [authentication](../auth/overview): - + ```wasp title="main.wasp" app TodoApp { @@ -86,7 +86,7 @@ Wasp also supports authentication using [Google](../auth/social-auth/google), [G Wasp creates the login and signup forms for us, but we still need to define the pages to display those forms on. We'll start by declaring the pages in the Wasp file: - + ```wasp title="main.wasp" // ... @@ -106,7 +106,7 @@ Great, Wasp now knows these pages exist! Here's the React code for the pages you've just imported: - + ```tsx title="src/LoginPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -127,7 +127,7 @@ export const LoginPage = () => { The signup page is very similar to the login page: - + ```tsx title="src/SignupPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -156,7 +156,7 @@ export const SignupPage = () => { We don't want users who are not logged in to access the main page, because they won't be able to create any tasks. So let's make the page private by requiring the user to be logged in: - + ```wasp title="main.wasp" // ... @@ -172,7 +172,7 @@ Now that auth is required for this page, unauthenticated users will be redirecte Additionally, when `authRequired` is `true`, the page's React component will be provided a `user` object as prop. - + ```tsx title="src/MainPage.tsx" auto-js import type { AuthUser } from "wasp/auth"; @@ -208,7 +208,7 @@ However, you will notice that if you try logging in as different users and creat First, let's define a one-to-many relation between users and tasks (check the [Prisma docs on relations](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/relations)): - + ```prisma title="schema.prisma" // ... @@ -249,7 +249,7 @@ Instead, we would do a data migration to take care of those tasks, even if it me Next, let's update the queries and actions to forbid access to non-authenticated users and to operate only on the currently logged-in user's tasks: - + ```ts title="src/queries.ts" auto-js import type { Task } from "wasp/entities"; @@ -271,7 +271,7 @@ export const getTasks: GetTasks = async (args, context) => { }; ``` - + ```ts title="src/actions.ts" auto-js import type { Task } from "wasp/entities"; @@ -337,7 +337,7 @@ You will see that each user has their tasks, just as we specified in our code! Last, but not least, let's add the logout functionality: - + ```tsx title="src/MainPage.tsx" auto-js with-hole // ... diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index 6c324f86b9..c8584900eb 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -1,6 +1,6 @@ import React from "react"; -type Action = "diff" | "write" | "migrate-db"; +type Action = "apply-patch" | "write" | "migrate-db"; /* diff --git a/web/tutorial-app-generator/src/actions/index.ts b/web/tutorial-app-generator/src/actions/index.ts deleted file mode 100644 index 6c1992e2a3..0000000000 --- a/web/tutorial-app-generator/src/actions/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -import fs from "fs/promises"; -import path from "path"; - -import { $ } from "zx"; - -import Enquirer from "enquirer"; -import { assertValidPatchFile } from "../diff"; -import { log } from "../log"; -import { waspDbMigrate } from "../waspCli"; - -export type ActionCommon = { - stepName: string; - markdownSourceFilePath: string; -}; - -export type ApplyPatchAction = { - kind: "diff"; - patchContentPath: string; -} & ActionCommon; - -export type MigrateDbAction = { - kind: "migrate-db"; -} & ActionCommon; - -export type Action = ApplyPatchAction | MigrateDbAction; - -export async function tryToFixPatch( - appDir: string, - action: ApplyPatchAction, -): Promise { - log("info", `Trying to fix patch for step: ${action.stepName}`); - - const patchPath = path.resolve(appDir, action.patchContentPath); - if (await fs.stat(patchPath).catch(() => false)) { - log("info", `Removing existing patch file: ${patchPath}`); - await fs.unlink(patchPath); - } - - await createPatchForStep(appDir, action); -} - -export async function createPatchForStep( - appDir: string, - action: ApplyPatchAction, -) { - await Enquirer.prompt({ - type: "confirm", - name: "edit", - message: `Apply edit for ${action.stepName} and press Enter`, - initial: true, - }); - const patch = await generatePatchFromChanges(appDir); - await fs.writeFile(action.patchContentPath, patch, "utf-8"); - log("info", `Patch file created: ${action.patchContentPath}`); - - assertValidPatchFile(action.patchContentPath); -} - -export async function generatePatchFromChanges( - appDir: string, -): Promise { - const temporaryTagName = "temporary-patch-tag"; - const { stdout: patch } = await $`cd ${appDir} && - git add . && - git commit -m "${temporaryTagName}" && - git show --format= ${temporaryTagName} - git reset --hard HEAD~1 - `.verbose(false); - return patch; -} - -export async function commitStep(appDir: string, stepName: string) { - await $`cd ${appDir} && git add . && git commit -m "${stepName}" && git tag ${stepName}`; - log("info", `Committed step ${stepName}`); -} - -export async function applyPatch(appDir: string, patchContentPath: string) { - await $`cd ${appDir} && git apply ${patchContentPath} --verbose`.quiet(true); -} - -export const migrateDb = waspDbMigrate; - -export function createApplyPatchAction( - commonActionData: ActionCommon, -): ApplyPatchAction { - const patchContentPath = path.resolve( - "../docs/tutorial", - "patches", - `${commonActionData.stepName}.patch`, - ); - - return { - ...commonActionData, - kind: "diff", - patchContentPath, - }; -} diff --git a/web/tutorial-app-generator/src/diff.ts b/web/tutorial-app-generator/src/diff.ts deleted file mode 100644 index 0380608702..0000000000 --- a/web/tutorial-app-generator/src/diff.ts +++ /dev/null @@ -1,13 +0,0 @@ -import fs from "fs"; - -import parseGitDiff from "parse-git-diff"; - -export function assertValidPatchFile(patchContentPath: string): void { - const patch = fs.readFileSync(patchContentPath, "utf-8"); - - const parsedPatch = parseGitDiff(patch); - - if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { - throw new Error("Invalid patch: no changes found"); - } -} diff --git a/web/tutorial-app-generator/src/executeSteps/actions.ts b/web/tutorial-app-generator/src/executeSteps/actions.ts new file mode 100644 index 0000000000..52d9c15752 --- /dev/null +++ b/web/tutorial-app-generator/src/executeSteps/actions.ts @@ -0,0 +1,33 @@ +import path from "path"; + +export type ActionCommon = { + stepName: string; + markdownSourceFilePath: string; +}; + +export type ApplyPatchAction = { + kind: "apply-patch"; + patchContentPath: string; +} & ActionCommon; + +export type MigrateDbAction = { + kind: "migrate-db"; +} & ActionCommon; + +export type Action = ApplyPatchAction | MigrateDbAction; + +export function createApplyPatchAction( + commonActionData: ActionCommon, +): ApplyPatchAction { + const patchContentPath = path.resolve( + "../docs/tutorial", + "patches", + `${commonActionData.stepName}.patch`, + ); + + return { + ...commonActionData, + kind: "apply-patch", + patchContentPath, + }; +} diff --git a/web/tutorial-app-generator/src/execute-steps/index.ts b/web/tutorial-app-generator/src/executeSteps/index.ts similarity index 73% rename from web/tutorial-app-generator/src/execute-steps/index.ts rename to web/tutorial-app-generator/src/executeSteps/index.ts index 9261fe468e..238ae34966 100644 --- a/web/tutorial-app-generator/src/execute-steps/index.ts +++ b/web/tutorial-app-generator/src/executeSteps/index.ts @@ -1,26 +1,22 @@ import { chalk } from "zx"; -import { - applyPatch, - commitStep, - migrateDb, - tryToFixPatch, - type Action, -} from "../actions"; +import { commitAllChangesUnderTag } from "../git"; import { log } from "../log"; import { appDir, ensureDirExists, patchesDir } from "../paths"; +import { waspDbMigrate } from "../waspCli"; +import { type Action } from "./actions"; +import { applyPatch, tryToFixPatch } from "./patch"; export async function executeSteps(actions: Action[]): Promise { for (const action of actions) { const kind = action.kind; log("info", `${chalk.bold(`[step ${action.stepName}]`)} ${kind}`); - // Prepare the patches directory await ensureDirExists(patchesDir); try { switch (kind) { - case "diff": + case "apply-patch": try { await applyPatch(appDir, action.patchContentPath); } catch (err) { @@ -32,7 +28,7 @@ export async function executeSteps(actions: Action[]): Promise { } break; case "migrate-db": - await migrateDb(appDir, `step-${action.stepName}`); + await waspDbMigrate(appDir, `step-${action.stepName}`); break; default: kind satisfies never; @@ -41,6 +37,6 @@ export async function executeSteps(actions: Action[]): Promise { log("error", `Error in step ${action.stepName}:\n\n${err}`); process.exit(1); } - await commitStep(appDir, action.stepName); + await commitAllChangesUnderTag(appDir, action.stepName); } } diff --git a/web/tutorial-app-generator/src/executeSteps/patch.ts b/web/tutorial-app-generator/src/executeSteps/patch.ts new file mode 100644 index 0000000000..d7b5b9869d --- /dev/null +++ b/web/tutorial-app-generator/src/executeSteps/patch.ts @@ -0,0 +1,54 @@ +import fs from "fs/promises"; +import path from "path"; + +import Enquirer from "enquirer"; +import { $ } from "zx"; + +import parseGitDiff from "parse-git-diff"; + +import { generatePatchFromChanges } from "../git"; +import { log } from "../log"; +import type { ApplyPatchAction } from "./actions"; + +export async function applyPatch(appDir: string, patchContentPath: string) { + await $`cd ${appDir} && git apply ${patchContentPath} --verbose`.quiet(true); +} + +export async function tryToFixPatch( + appDir: string, + action: ApplyPatchAction, +): Promise { + log("info", `Trying to fix patch for step: ${action.stepName}`); + + const patchPath = path.resolve(appDir, action.patchContentPath); + if (await fs.stat(patchPath).catch(() => false)) { + log("info", `Removing existing patch file: ${patchPath}`); + await fs.unlink(patchPath); + } + + await createPatchForStep(appDir, action); +} + +export async function createPatchForStep( + appDir: string, + action: ApplyPatchAction, +) { + await Enquirer.prompt({ + type: "confirm", + name: "edit", + message: `Apply edit for ${action.stepName} and press Enter`, + initial: true, + }); + const patch = await generatePatchFromChanges(appDir); + assertValidPatch(patch); + await fs.writeFile(action.patchContentPath, patch, "utf-8"); + log("info", `Patch file created: ${action.patchContentPath}`); +} + +export async function assertValidPatch(patch: string): Promise { + const parsedPatch = parseGitDiff(patch); + + if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { + throw new Error("Invalid patch: no changes found"); + } +} diff --git a/web/tutorial-app-generator/src/markdown/customHeadingId.ts b/web/tutorial-app-generator/src/extractSteps/customHeadingId.ts similarity index 100% rename from web/tutorial-app-generator/src/markdown/customHeadingId.ts rename to web/tutorial-app-generator/src/extractSteps/customHeadingId.ts diff --git a/web/tutorial-app-generator/src/markdown/extractSteps.ts b/web/tutorial-app-generator/src/extractSteps/index.ts similarity index 97% rename from web/tutorial-app-generator/src/markdown/extractSteps.ts rename to web/tutorial-app-generator/src/extractSteps/index.ts index 436f4f81f1..19c444c0af 100644 --- a/web/tutorial-app-generator/src/markdown/extractSteps.ts +++ b/web/tutorial-app-generator/src/extractSteps/index.ts @@ -12,7 +12,7 @@ import { createApplyPatchAction, type Action, type ActionCommon, -} from "../actions/index"; +} from "../executeSteps/actions.js"; const componentName = "TutorialAction"; @@ -74,7 +74,7 @@ async function getActionsFromFile(filePath: string): Promise { kind: "migrate-db", }); return; - } else if (action === "diff") { + } else if (action === "apply-patch") { const patchAction = createApplyPatchAction(commonActionData); actions.push(patchAction); return; diff --git a/web/tutorial-app-generator/src/git.ts b/web/tutorial-app-generator/src/git.ts new file mode 100644 index 0000000000..be7cfb7a17 --- /dev/null +++ b/web/tutorial-app-generator/src/git.ts @@ -0,0 +1,22 @@ +import { $ } from "zx"; +import { log } from "./log"; + +export async function commitAllChangesUnderTag( + appDir: string, + tagName: string, +) { + await $`cd ${appDir} && git add . && git commit -m "${tagName}" && git tag ${tagName}`; + log("info", `Tagged changes with "${tagName}"`); +} + +export async function generatePatchFromChanges( + appDir: string, +): Promise { + const { stdout: patch } = await $`cd ${appDir} && + git add . && + git commit -m "temporary-commit" && + git show --format= + git reset --hard HEAD~1 + `.verbose(false); + return patch; +} diff --git a/web/tutorial-app-generator/src/index.ts b/web/tutorial-app-generator/src/index.ts index c2eb73d6fe..152daeb60e 100644 --- a/web/tutorial-app-generator/src/index.ts +++ b/web/tutorial-app-generator/src/index.ts @@ -1,13 +1,12 @@ import { $ } from "zx"; import { program } from "@commander-js/extra-typings"; -import type { Action } from "./actions/index"; -import { getActionsFromTutorialFiles } from "./markdown/extractSteps"; +import type { Action } from "./executeSteps/actions"; +import { executeSteps } from "./executeSteps/index"; +import { getActionsFromTutorialFiles } from "./extractSteps/index"; import { appDir } from "./paths"; import { waspNew } from "./waspCli"; -import { executeSteps } from "./execute-steps"; - const actions: Action[] = await getActionsFromTutorialFiles(); function findStepOrThrow(stepName: string): Action { @@ -32,19 +31,3 @@ async function prepareApp() { await prepareApp(); await executeSteps(actions); - -// Commit -> patch -// git format-patch -1 migration-connect-task-user --stdout - -// git switch -c fixes $step4commitSHA -// git commit -a --fixup=$step4commitSHA -// git switch main -// git rebase fixes -// git rebase --root --autosquash - -// You can't just change stuff... you need to have the old commits -// ready first - then you execute "change step" and it will regenerate -// the patches for you. - -// Do we just use Git for this workflow? Or we wrap the Git workflow -// in this tutorial app generator CLI? From 1110026489f01380e1308587f903b2d1ec9838c7 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 17 Jul 2025 15:29:25 +0200 Subject: [PATCH 15/48] Restructure --- ....patch => 03-pages__prepare-project.patch} | 0 ...k.patch => 04-entities__prisma-task.patch} | 0 ...atch => 05-queries__main-page-tasks.patch} | 0 ...=> 05-queries__query-get-tasks-impl.patch} | 0 ...atch => 05-queries__query-get-tasks.patch} | 0 ...06-actions__action-create-task-impl.patch} | 0 ...h => 06-actions__action-create-task.patch} | 0 ...06-actions__action-update-task-impl.patch} | 0 ...h => 06-actions__action-update-task.patch} | 0 ...ns__main-page-create-task-impl-form.patch} | 0 ...ons__main-page-create-task-use-form.patch} | 0 ...> 06-actions__main-page-update-task.patch} | 0 ...h.patch => 07-auth__action-add-auth.patch} | 0 ...atch => 07-auth__login-page-initial.patch} | 0 ...atch => 07-auth__main-page-add-auth.patch} | 0 ...ch => 07-auth__main-page-add-logout.patch} | 0 ...> 07-auth__prisma-connect-task-user.patch} | 0 ...-user.patch => 07-auth__prisma-user.patch} | 0 ...th.patch => 07-auth__query-add-auth.patch} | 0 ...tch => 07-auth__signup-page-initial.patch} | 0 ...=> 07-auth__wasp-file-auth-required.patch} | 0 ...h => 07-auth__wasp-file-auth-routes.patch} | 0 ...th.patch => 07-auth__wasp-file-auth.patch} | 0 .../src/commands/extract-commits/index.ts | 15 ++++++++ .../src/commands/generate-app/index.ts | 26 ++++++++++++++ .../src/executeSteps/actions.ts | 6 +++- web/tutorial-app-generator/src/index.ts | 34 ++++--------------- web/tutorial-app-generator/src/log.ts | 1 + 28 files changed, 54 insertions(+), 28 deletions(-) rename web/docs/tutorial/patches/{prepare-project.patch => 03-pages__prepare-project.patch} (100%) rename web/docs/tutorial/patches/{prisma-task.patch => 04-entities__prisma-task.patch} (100%) rename web/docs/tutorial/patches/{main-page-tasks.patch => 05-queries__main-page-tasks.patch} (100%) rename web/docs/tutorial/patches/{query-get-tasks-impl.patch => 05-queries__query-get-tasks-impl.patch} (100%) rename web/docs/tutorial/patches/{query-get-tasks.patch => 05-queries__query-get-tasks.patch} (100%) rename web/docs/tutorial/patches/{action-create-task-impl.patch => 06-actions__action-create-task-impl.patch} (100%) rename web/docs/tutorial/patches/{action-create-task.patch => 06-actions__action-create-task.patch} (100%) rename web/docs/tutorial/patches/{action-update-task-impl.patch => 06-actions__action-update-task-impl.patch} (100%) rename web/docs/tutorial/patches/{action-update-task.patch => 06-actions__action-update-task.patch} (100%) rename web/docs/tutorial/patches/{main-page-create-task-impl-form.patch => 06-actions__main-page-create-task-impl-form.patch} (100%) rename web/docs/tutorial/patches/{main-page-create-task-use-form.patch => 06-actions__main-page-create-task-use-form.patch} (100%) rename web/docs/tutorial/patches/{main-page-update-task.patch => 06-actions__main-page-update-task.patch} (100%) rename web/docs/tutorial/patches/{action-add-auth.patch => 07-auth__action-add-auth.patch} (100%) rename web/docs/tutorial/patches/{login-page-initial.patch => 07-auth__login-page-initial.patch} (100%) rename web/docs/tutorial/patches/{main-page-add-auth.patch => 07-auth__main-page-add-auth.patch} (100%) rename web/docs/tutorial/patches/{main-page-add-logout.patch => 07-auth__main-page-add-logout.patch} (100%) rename web/docs/tutorial/patches/{prisma-connect-task-user.patch => 07-auth__prisma-connect-task-user.patch} (100%) rename web/docs/tutorial/patches/{prisma-user.patch => 07-auth__prisma-user.patch} (100%) rename web/docs/tutorial/patches/{query-add-auth.patch => 07-auth__query-add-auth.patch} (100%) rename web/docs/tutorial/patches/{signup-page-initial.patch => 07-auth__signup-page-initial.patch} (100%) rename web/docs/tutorial/patches/{wasp-file-auth-required.patch => 07-auth__wasp-file-auth-required.patch} (100%) rename web/docs/tutorial/patches/{wasp-file-auth-routes.patch => 07-auth__wasp-file-auth-routes.patch} (100%) rename web/docs/tutorial/patches/{wasp-file-auth.patch => 07-auth__wasp-file-auth.patch} (100%) create mode 100644 web/tutorial-app-generator/src/commands/extract-commits/index.ts create mode 100644 web/tutorial-app-generator/src/commands/generate-app/index.ts diff --git a/web/docs/tutorial/patches/prepare-project.patch b/web/docs/tutorial/patches/03-pages__prepare-project.patch similarity index 100% rename from web/docs/tutorial/patches/prepare-project.patch rename to web/docs/tutorial/patches/03-pages__prepare-project.patch diff --git a/web/docs/tutorial/patches/prisma-task.patch b/web/docs/tutorial/patches/04-entities__prisma-task.patch similarity index 100% rename from web/docs/tutorial/patches/prisma-task.patch rename to web/docs/tutorial/patches/04-entities__prisma-task.patch diff --git a/web/docs/tutorial/patches/main-page-tasks.patch b/web/docs/tutorial/patches/05-queries__main-page-tasks.patch similarity index 100% rename from web/docs/tutorial/patches/main-page-tasks.patch rename to web/docs/tutorial/patches/05-queries__main-page-tasks.patch diff --git a/web/docs/tutorial/patches/query-get-tasks-impl.patch b/web/docs/tutorial/patches/05-queries__query-get-tasks-impl.patch similarity index 100% rename from web/docs/tutorial/patches/query-get-tasks-impl.patch rename to web/docs/tutorial/patches/05-queries__query-get-tasks-impl.patch diff --git a/web/docs/tutorial/patches/query-get-tasks.patch b/web/docs/tutorial/patches/05-queries__query-get-tasks.patch similarity index 100% rename from web/docs/tutorial/patches/query-get-tasks.patch rename to web/docs/tutorial/patches/05-queries__query-get-tasks.patch diff --git a/web/docs/tutorial/patches/action-create-task-impl.patch b/web/docs/tutorial/patches/06-actions__action-create-task-impl.patch similarity index 100% rename from web/docs/tutorial/patches/action-create-task-impl.patch rename to web/docs/tutorial/patches/06-actions__action-create-task-impl.patch diff --git a/web/docs/tutorial/patches/action-create-task.patch b/web/docs/tutorial/patches/06-actions__action-create-task.patch similarity index 100% rename from web/docs/tutorial/patches/action-create-task.patch rename to web/docs/tutorial/patches/06-actions__action-create-task.patch diff --git a/web/docs/tutorial/patches/action-update-task-impl.patch b/web/docs/tutorial/patches/06-actions__action-update-task-impl.patch similarity index 100% rename from web/docs/tutorial/patches/action-update-task-impl.patch rename to web/docs/tutorial/patches/06-actions__action-update-task-impl.patch diff --git a/web/docs/tutorial/patches/action-update-task.patch b/web/docs/tutorial/patches/06-actions__action-update-task.patch similarity index 100% rename from web/docs/tutorial/patches/action-update-task.patch rename to web/docs/tutorial/patches/06-actions__action-update-task.patch diff --git a/web/docs/tutorial/patches/main-page-create-task-impl-form.patch b/web/docs/tutorial/patches/06-actions__main-page-create-task-impl-form.patch similarity index 100% rename from web/docs/tutorial/patches/main-page-create-task-impl-form.patch rename to web/docs/tutorial/patches/06-actions__main-page-create-task-impl-form.patch diff --git a/web/docs/tutorial/patches/main-page-create-task-use-form.patch b/web/docs/tutorial/patches/06-actions__main-page-create-task-use-form.patch similarity index 100% rename from web/docs/tutorial/patches/main-page-create-task-use-form.patch rename to web/docs/tutorial/patches/06-actions__main-page-create-task-use-form.patch diff --git a/web/docs/tutorial/patches/main-page-update-task.patch b/web/docs/tutorial/patches/06-actions__main-page-update-task.patch similarity index 100% rename from web/docs/tutorial/patches/main-page-update-task.patch rename to web/docs/tutorial/patches/06-actions__main-page-update-task.patch diff --git a/web/docs/tutorial/patches/action-add-auth.patch b/web/docs/tutorial/patches/07-auth__action-add-auth.patch similarity index 100% rename from web/docs/tutorial/patches/action-add-auth.patch rename to web/docs/tutorial/patches/07-auth__action-add-auth.patch diff --git a/web/docs/tutorial/patches/login-page-initial.patch b/web/docs/tutorial/patches/07-auth__login-page-initial.patch similarity index 100% rename from web/docs/tutorial/patches/login-page-initial.patch rename to web/docs/tutorial/patches/07-auth__login-page-initial.patch diff --git a/web/docs/tutorial/patches/main-page-add-auth.patch b/web/docs/tutorial/patches/07-auth__main-page-add-auth.patch similarity index 100% rename from web/docs/tutorial/patches/main-page-add-auth.patch rename to web/docs/tutorial/patches/07-auth__main-page-add-auth.patch diff --git a/web/docs/tutorial/patches/main-page-add-logout.patch b/web/docs/tutorial/patches/07-auth__main-page-add-logout.patch similarity index 100% rename from web/docs/tutorial/patches/main-page-add-logout.patch rename to web/docs/tutorial/patches/07-auth__main-page-add-logout.patch diff --git a/web/docs/tutorial/patches/prisma-connect-task-user.patch b/web/docs/tutorial/patches/07-auth__prisma-connect-task-user.patch similarity index 100% rename from web/docs/tutorial/patches/prisma-connect-task-user.patch rename to web/docs/tutorial/patches/07-auth__prisma-connect-task-user.patch diff --git a/web/docs/tutorial/patches/prisma-user.patch b/web/docs/tutorial/patches/07-auth__prisma-user.patch similarity index 100% rename from web/docs/tutorial/patches/prisma-user.patch rename to web/docs/tutorial/patches/07-auth__prisma-user.patch diff --git a/web/docs/tutorial/patches/query-add-auth.patch b/web/docs/tutorial/patches/07-auth__query-add-auth.patch similarity index 100% rename from web/docs/tutorial/patches/query-add-auth.patch rename to web/docs/tutorial/patches/07-auth__query-add-auth.patch diff --git a/web/docs/tutorial/patches/signup-page-initial.patch b/web/docs/tutorial/patches/07-auth__signup-page-initial.patch similarity index 100% rename from web/docs/tutorial/patches/signup-page-initial.patch rename to web/docs/tutorial/patches/07-auth__signup-page-initial.patch diff --git a/web/docs/tutorial/patches/wasp-file-auth-required.patch b/web/docs/tutorial/patches/07-auth__wasp-file-auth-required.patch similarity index 100% rename from web/docs/tutorial/patches/wasp-file-auth-required.patch rename to web/docs/tutorial/patches/07-auth__wasp-file-auth-required.patch diff --git a/web/docs/tutorial/patches/wasp-file-auth-routes.patch b/web/docs/tutorial/patches/07-auth__wasp-file-auth-routes.patch similarity index 100% rename from web/docs/tutorial/patches/wasp-file-auth-routes.patch rename to web/docs/tutorial/patches/07-auth__wasp-file-auth-routes.patch diff --git a/web/docs/tutorial/patches/wasp-file-auth.patch b/web/docs/tutorial/patches/07-auth__wasp-file-auth.patch similarity index 100% rename from web/docs/tutorial/patches/wasp-file-auth.patch rename to web/docs/tutorial/patches/07-auth__wasp-file-auth.patch diff --git a/web/tutorial-app-generator/src/commands/extract-commits/index.ts b/web/tutorial-app-generator/src/commands/extract-commits/index.ts new file mode 100644 index 0000000000..1babdaacc9 --- /dev/null +++ b/web/tutorial-app-generator/src/commands/extract-commits/index.ts @@ -0,0 +1,15 @@ +import { Command } from "@commander-js/extra-typings"; + +import type { Action } from "../../executeSteps/actions"; +import { getActionsFromTutorialFiles } from "../../extractSteps"; +import { log } from "../../log"; + +export const extractCommitsCommand = new Command("extract-commits") + .description("Extract commits into patch files for the tutorial app") + .action(async () => { + const actions: Action[] = await getActionsFromTutorialFiles(); + + // TODO: implement command that extracts commits into patch files + + log("success", "Commits successfully extracted into patch files!"); + }); diff --git a/web/tutorial-app-generator/src/commands/generate-app/index.ts b/web/tutorial-app-generator/src/commands/generate-app/index.ts new file mode 100644 index 0000000000..51f31af31e --- /dev/null +++ b/web/tutorial-app-generator/src/commands/generate-app/index.ts @@ -0,0 +1,26 @@ +import { $ } from "zx"; + +import { Command } from "@commander-js/extra-typings"; +import { executeSteps } from "../../executeSteps"; +import type { Action } from "../../executeSteps/actions"; +import { getActionsFromTutorialFiles } from "../../extractSteps"; +import { log } from "../../log"; +import { appDir } from "../../paths"; +import { waspNew } from "../../waspCli"; + +export const generateAppCommand = new Command("generate-app") + .description("Generate a new Wasp app based on the tutorial steps") + .action(async () => { + const actions: Action[] = await getActionsFromTutorialFiles(); + + await prepareApp(); + await executeSteps(actions); + log("success", "Tutorial app has been successfully generated!"); + }); + +async function prepareApp() { + await $`rm -rf ${appDir}`; + await waspNew(appDir); + // Git needs to be initialized for patches to work + await $`cd ${appDir} && git init && git add . && git commit -m "Initial commit"`; +} diff --git a/web/tutorial-app-generator/src/executeSteps/actions.ts b/web/tutorial-app-generator/src/executeSteps/actions.ts index 52d9c15752..b0d5b4961a 100644 --- a/web/tutorial-app-generator/src/executeSteps/actions.ts +++ b/web/tutorial-app-generator/src/executeSteps/actions.ts @@ -19,10 +19,14 @@ export type Action = ApplyPatchAction | MigrateDbAction; export function createApplyPatchAction( commonActionData: ActionCommon, ): ApplyPatchAction { + const markdownFileWithoutExt = path.basename( + commonActionData.markdownSourceFilePath, + path.extname(commonActionData.markdownSourceFilePath), + ); const patchContentPath = path.resolve( "../docs/tutorial", "patches", - `${commonActionData.stepName}.patch`, + `${markdownFileWithoutExt}__${commonActionData.stepName}.patch`, ); return { diff --git a/web/tutorial-app-generator/src/index.ts b/web/tutorial-app-generator/src/index.ts index 152daeb60e..0bd34e0955 100644 --- a/web/tutorial-app-generator/src/index.ts +++ b/web/tutorial-app-generator/src/index.ts @@ -1,33 +1,13 @@ import { $ } from "zx"; import { program } from "@commander-js/extra-typings"; -import type { Action } from "./executeSteps/actions"; -import { executeSteps } from "./executeSteps/index"; -import { getActionsFromTutorialFiles } from "./extractSteps/index"; -import { appDir } from "./paths"; -import { waspNew } from "./waspCli"; +import { extractCommitsCommand } from "./commands/extract-commits"; +import { generateAppCommand } from "./commands/generate-app"; -const actions: Action[] = await getActionsFromTutorialFiles(); - -function findStepOrThrow(stepName: string): Action { - const action = actions.find((action) => action.stepName === stepName); - if (!action) { - throw new Error(`No action found for step ${stepName}.`); - } - return action; -} - -const _args = program.parse(process.argv).opts(); +const _args = program + .addCommand(generateAppCommand) + .addCommand(extractCommitsCommand) + .parse(process.argv) + .opts(); $.verbose = true; - -async function prepareApp() { - await $`rm -rf ${appDir}`; - await waspNew(appDir); - // Git needs to be initialized for patches to work - await $`cd ${appDir} && git init && git add . && git commit -m "Initial commit"`; -} - -await prepareApp(); - -await executeSteps(actions); diff --git a/web/tutorial-app-generator/src/log.ts b/web/tutorial-app-generator/src/log.ts index 120dad6b17..0407b0ae75 100644 --- a/web/tutorial-app-generator/src/log.ts +++ b/web/tutorial-app-generator/src/log.ts @@ -2,6 +2,7 @@ import { chalk } from "zx"; const colors = { info: chalk.blue, + success: chalk.green, error: chalk.red, }; From eee3f77abb670b4ed4d0fbc36d8b226bc4e34649 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 17 Jul 2025 19:46:41 +0200 Subject: [PATCH 16/48] Refactor. Add edit step command. --- .../src/commands/edit-step/index.ts | 65 +++++++++++++++++++ .../src/commands/extract-commits/index.ts | 15 ----- .../src/commands/generate-app/index.ts | 10 ++- .../src/executeSteps/index.ts | 7 +- .../src/executeSteps/patch.ts | 5 +- .../src/extractSteps/index.ts | 9 +-- web/tutorial-app-generator/src/files.ts | 14 ++++ web/tutorial-app-generator/src/git.ts | 35 ++++++---- web/tutorial-app-generator/src/index.ts | 8 +-- web/tutorial-app-generator/src/paths.ts | 8 --- web/tutorial-app-generator/src/project.ts | 3 + web/tutorial-app-generator/src/waspCli.ts | 3 +- 12 files changed, 125 insertions(+), 57 deletions(-) create mode 100644 web/tutorial-app-generator/src/commands/edit-step/index.ts delete mode 100644 web/tutorial-app-generator/src/commands/extract-commits/index.ts create mode 100644 web/tutorial-app-generator/src/files.ts delete mode 100644 web/tutorial-app-generator/src/paths.ts create mode 100644 web/tutorial-app-generator/src/project.ts diff --git a/web/tutorial-app-generator/src/commands/edit-step/index.ts b/web/tutorial-app-generator/src/commands/edit-step/index.ts new file mode 100644 index 0000000000..87013d37c8 --- /dev/null +++ b/web/tutorial-app-generator/src/commands/edit-step/index.ts @@ -0,0 +1,65 @@ +import fs from "fs/promises"; + +import { Command, Option } from "@commander-js/extra-typings"; +import { $ } from "zx"; + +import Enquirer from "enquirer"; +import type { Action } from "../../executeSteps/actions"; +import { getActionsFromTutorialFiles } from "../../extractSteps"; +import { getCommitPatch } from "../../git"; +import { log } from "../../log"; +import { appDir, mainBranchName } from "../../project"; + +export const editStepCommand = new Command("edit-step") + .description("Edit a step in the tutorial app") + .addOption(new Option("--step-name ", "Name of the step to edit")) + .action(async ({ stepName }) => { + const actions: Action[] = await getActionsFromTutorialFiles(); + + const action = actions.find((a) => a.stepName === stepName); + + if (!action) { + throw new Error(`Step with name "${stepName}" not found.`); + } + + const fixesBranchName = "fixes"; + await $({ + cwd: appDir, + })`git switch -c ${fixesBranchName} ${action.stepName}`; + + await Enquirer.prompt({ + type: "confirm", + name: "edit", + message: `Apply edit for step "${action.stepName}" and press Enter`, + initial: true, + }); + + await $({ cwd: appDir })`git add .`; + await $({ cwd: appDir })`git commit --amend --no-edit`; + await $({ cwd: appDir })`git tag -f ${action.stepName}`; + await $({ cwd: appDir })`git switch ${mainBranchName}`; + await $({ cwd: appDir, throw: false })`git rebase ${fixesBranchName}`; + + await Enquirer.prompt({ + type: "confirm", + name: "issues", + message: `If there are any rebase issues, resolve them and press Enter to continue`, + initial: true, + }); + + await extractCommitsIntoPatches(actions); + + log("success", `Edit completed for step ${action.stepName}!`); + }); + +async function extractCommitsIntoPatches(actions: Action[]): Promise { + for (const action of actions) { + if (action.kind !== "apply-patch") { + continue; + } + + log("info", `Updating patch for step ${action.stepName}`); + const patch = await getCommitPatch(appDir, action.stepName); + await fs.writeFile(action.patchContentPath, patch, "utf-8"); + } +} diff --git a/web/tutorial-app-generator/src/commands/extract-commits/index.ts b/web/tutorial-app-generator/src/commands/extract-commits/index.ts deleted file mode 100644 index 1babdaacc9..0000000000 --- a/web/tutorial-app-generator/src/commands/extract-commits/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Command } from "@commander-js/extra-typings"; - -import type { Action } from "../../executeSteps/actions"; -import { getActionsFromTutorialFiles } from "../../extractSteps"; -import { log } from "../../log"; - -export const extractCommitsCommand = new Command("extract-commits") - .description("Extract commits into patch files for the tutorial app") - .action(async () => { - const actions: Action[] = await getActionsFromTutorialFiles(); - - // TODO: implement command that extracts commits into patch files - - log("success", "Commits successfully extracted into patch files!"); - }); diff --git a/web/tutorial-app-generator/src/commands/generate-app/index.ts b/web/tutorial-app-generator/src/commands/generate-app/index.ts index 51f31af31e..a20edc15eb 100644 --- a/web/tutorial-app-generator/src/commands/generate-app/index.ts +++ b/web/tutorial-app-generator/src/commands/generate-app/index.ts @@ -1,11 +1,11 @@ +import { Command } from "@commander-js/extra-typings"; import { $ } from "zx"; -import { Command } from "@commander-js/extra-typings"; import { executeSteps } from "../../executeSteps"; import type { Action } from "../../executeSteps/actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; import { log } from "../../log"; -import { appDir } from "../../paths"; +import { appDir, mainBranchName } from "../../project"; import { waspNew } from "../../waspCli"; export const generateAppCommand = new Command("generate-app") @@ -22,5 +22,9 @@ async function prepareApp() { await $`rm -rf ${appDir}`; await waspNew(appDir); // Git needs to be initialized for patches to work - await $`cd ${appDir} && git init && git add . && git commit -m "Initial commit"`; + await $({ cwd: appDir })`git init`.quiet(true); + await $({ cwd: appDir })`git branch -m ${mainBranchName}`; + await $({ cwd: appDir })`git add .`; + await $({ cwd: appDir })`git commit -m "Initial commit"`; + log("info", "Tutorial app directory has been initialized"); } diff --git a/web/tutorial-app-generator/src/executeSteps/index.ts b/web/tutorial-app-generator/src/executeSteps/index.ts index 238ae34966..dd5b8ff4fb 100644 --- a/web/tutorial-app-generator/src/executeSteps/index.ts +++ b/web/tutorial-app-generator/src/executeSteps/index.ts @@ -1,8 +1,9 @@ import { chalk } from "zx"; -import { commitAllChangesUnderTag } from "../git"; +import { ensureDirExists } from "../files"; +import { tagChanges } from "../git"; import { log } from "../log"; -import { appDir, ensureDirExists, patchesDir } from "../paths"; +import { appDir, patchesDir } from "../project"; import { waspDbMigrate } from "../waspCli"; import { type Action } from "./actions"; import { applyPatch, tryToFixPatch } from "./patch"; @@ -37,6 +38,6 @@ export async function executeSteps(actions: Action[]): Promise { log("error", `Error in step ${action.stepName}:\n\n${err}`); process.exit(1); } - await commitAllChangesUnderTag(appDir, action.stepName); + await tagChanges(appDir, action.stepName); } } diff --git a/web/tutorial-app-generator/src/executeSteps/patch.ts b/web/tutorial-app-generator/src/executeSteps/patch.ts index d7b5b9869d..9d4d3c54f0 100644 --- a/web/tutorial-app-generator/src/executeSteps/patch.ts +++ b/web/tutorial-app-generator/src/executeSteps/patch.ts @@ -6,12 +6,13 @@ import { $ } from "zx"; import parseGitDiff from "parse-git-diff"; +import { doesFileExist } from "../files"; import { generatePatchFromChanges } from "../git"; import { log } from "../log"; import type { ApplyPatchAction } from "./actions"; export async function applyPatch(appDir: string, patchContentPath: string) { - await $`cd ${appDir} && git apply ${patchContentPath} --verbose`.quiet(true); + await $({ cwd: appDir })`git apply ${patchContentPath} --verbose`.quiet(true); } export async function tryToFixPatch( @@ -21,7 +22,7 @@ export async function tryToFixPatch( log("info", `Trying to fix patch for step: ${action.stepName}`); const patchPath = path.resolve(appDir, action.patchContentPath); - if (await fs.stat(patchPath).catch(() => false)) { + if (await doesFileExist(patchPath)) { log("info", `Removing existing patch file: ${patchPath}`); await fs.unlink(patchPath); } diff --git a/web/tutorial-app-generator/src/extractSteps/index.ts b/web/tutorial-app-generator/src/extractSteps/index.ts index 19c444c0af..394d36de62 100644 --- a/web/tutorial-app-generator/src/extractSteps/index.ts +++ b/web/tutorial-app-generator/src/extractSteps/index.ts @@ -7,7 +7,6 @@ import { mdxJsxFromMarkdown, type MdxJsxFlowElement } from "mdast-util-mdx-jsx"; import { mdxJsx } from "micromark-extension-mdx-jsx"; import { visit } from "unist-util-visit"; -import searchAndReplace from "../../../src/remark/search-and-replace.js"; import { createApplyPatchAction, type Action, @@ -31,7 +30,7 @@ export async function getActionsFromTutorialFiles(): Promise { const actions: Action[] = []; for (const file of files) { console.log(`Processing file: ${file}`); - const fileActions = await getActionsFromFile( + const fileActions = await getActionsFromMarkdownFile( path.resolve("../docs/tutorial", file), ); actions.push(...fileActions); @@ -39,7 +38,7 @@ export async function getActionsFromTutorialFiles(): Promise { return actions; } -async function getActionsFromFile(filePath: string): Promise { +async function getActionsFromMarkdownFile(filePath: string): Promise { const actions = [] as Action[]; const doc = await fs.readFile(path.resolve(filePath)); @@ -48,10 +47,6 @@ async function getActionsFromFile(filePath: string): Promise { mdastExtensions: [mdxJsxFromMarkdown()], }); - // TODO: figure this out - // @ts-ignore - searchAndReplace.visitor(ast); - visit(ast, "mdxJsxFlowElement", (node) => { if (node.name !== componentName) { return; diff --git a/web/tutorial-app-generator/src/files.ts b/web/tutorial-app-generator/src/files.ts new file mode 100644 index 0000000000..fa6b86b908 --- /dev/null +++ b/web/tutorial-app-generator/src/files.ts @@ -0,0 +1,14 @@ +import fs from "fs/promises"; + +export async function ensureDirExists(dir: string): Promise { + await fs.mkdir(dir, { recursive: true }); +} + +export async function doesFileExist(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch (error) { + return false; + } +} diff --git a/web/tutorial-app-generator/src/git.ts b/web/tutorial-app-generator/src/git.ts index be7cfb7a17..8c37dd7dab 100644 --- a/web/tutorial-app-generator/src/git.ts +++ b/web/tutorial-app-generator/src/git.ts @@ -1,22 +1,33 @@ import { $ } from "zx"; + import { log } from "./log"; -export async function commitAllChangesUnderTag( - appDir: string, - tagName: string, -) { - await $`cd ${appDir} && git add . && git commit -m "${tagName}" && git tag ${tagName}`; +export async function tagChanges(gitRepoDir: string, tagName: string) { + await $({ cwd: gitRepoDir })`git add .`; + await $({ cwd: gitRepoDir })`git commit -m "${tagName}"`; + await $({ cwd: gitRepoDir })`git tag ${tagName}`; log("info", `Tagged changes with "${tagName}"`); } export async function generatePatchFromChanges( - appDir: string, + gitRepoDir: string, +): Promise { + await $({ cwd: gitRepoDir })`git add .`; + await $({ cwd: gitRepoDir })`git commit -m "temporary-commit"`; + + const patch = await getCommitPatch(gitRepoDir, "HEAD"); + + await $({ cwd: gitRepoDir })`git reset --hard HEAD~1`; + return patch; +} + +export async function getCommitPatch( + gitRepoDir: string, + gitRevision: string, ): Promise { - const { stdout: patch } = await $`cd ${appDir} && - git add . && - git commit -m "temporary-commit" && - git show --format= - git reset --hard HEAD~1 - `.verbose(false); + const { stdout: patch } = await $({ + cwd: gitRepoDir, + })`git show ${gitRevision} --format=`; + return patch; } diff --git a/web/tutorial-app-generator/src/index.ts b/web/tutorial-app-generator/src/index.ts index 0bd34e0955..ec9e939f05 100644 --- a/web/tutorial-app-generator/src/index.ts +++ b/web/tutorial-app-generator/src/index.ts @@ -1,13 +1,9 @@ -import { $ } from "zx"; - import { program } from "@commander-js/extra-typings"; -import { extractCommitsCommand } from "./commands/extract-commits"; +import { editStepCommand } from "./commands/edit-step"; import { generateAppCommand } from "./commands/generate-app"; const _args = program .addCommand(generateAppCommand) - .addCommand(extractCommitsCommand) + .addCommand(editStepCommand) .parse(process.argv) .opts(); - -$.verbose = true; diff --git a/web/tutorial-app-generator/src/paths.ts b/web/tutorial-app-generator/src/paths.ts deleted file mode 100644 index 7fe42df102..0000000000 --- a/web/tutorial-app-generator/src/paths.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { promises as fs } from "fs"; - -export const appDir = "TodoApp"; -export const patchesDir = "patches"; - -export async function ensureDirExists(dir: string): Promise { - await fs.mkdir(dir, { recursive: true }); -} diff --git a/web/tutorial-app-generator/src/project.ts b/web/tutorial-app-generator/src/project.ts new file mode 100644 index 0000000000..a2eb8d4bab --- /dev/null +++ b/web/tutorial-app-generator/src/project.ts @@ -0,0 +1,3 @@ +export const appDir = "TodoApp"; +export const patchesDir = "patches"; +export const mainBranchName = "main"; diff --git a/web/tutorial-app-generator/src/waspCli.ts b/web/tutorial-app-generator/src/waspCli.ts index cd64b6041d..c8beae5acc 100644 --- a/web/tutorial-app-generator/src/waspCli.ts +++ b/web/tutorial-app-generator/src/waspCli.ts @@ -7,7 +7,8 @@ export async function waspDbMigrate( await $({ // Needs to inhert stdio for `wasp db migrate-dev` to work stdio: "inherit", - })`cd ${appDir} && wasp db migrate-dev --name ${migrationName}`; + cwd: appDir, + })`wasp db migrate-dev --name ${migrationName}`; } export async function waspNew(appDir: string): Promise { From 3ea853090402cd63781eda5cfc2cd765462f8de1 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 18 Jul 2025 00:09:09 +0200 Subject: [PATCH 17/48] Branded types, cleanup --- .../src/brandedTypes.ts | 6 ++ .../src/commands/edit-step/index.ts | 72 ++++++++++++------- .../src/commands/generate-app/index.ts | 17 +++-- .../src/executeSteps/actions.ts | 5 +- .../src/executeSteps/index.ts | 12 +++- .../src/executeSteps/patch.ts | 24 ++++--- .../src/extractSteps/customHeadingId.ts | 18 ----- .../src/extractSteps/index.ts | 3 +- web/tutorial-app-generator/src/files.ts | 4 +- web/tutorial-app-generator/src/project.ts | 11 ++- web/tutorial-app-generator/src/typeUtils.ts | 4 ++ web/tutorial-app-generator/src/waspCli.ts | 7 +- 12 files changed, 114 insertions(+), 69 deletions(-) create mode 100644 web/tutorial-app-generator/src/brandedTypes.ts delete mode 100644 web/tutorial-app-generator/src/extractSteps/customHeadingId.ts create mode 100644 web/tutorial-app-generator/src/typeUtils.ts diff --git a/web/tutorial-app-generator/src/brandedTypes.ts b/web/tutorial-app-generator/src/brandedTypes.ts new file mode 100644 index 0000000000..5b6ffa4728 --- /dev/null +++ b/web/tutorial-app-generator/src/brandedTypes.ts @@ -0,0 +1,6 @@ +import type { Branded } from "./typeUtils"; + +export type AppName = Branded; +export type AppDirPath = Branded; +export type PatchesDirPath = Branded; +export type PatchContentPath = Branded; diff --git a/web/tutorial-app-generator/src/commands/edit-step/index.ts b/web/tutorial-app-generator/src/commands/edit-step/index.ts index 87013d37c8..58cd0c0eef 100644 --- a/web/tutorial-app-generator/src/commands/edit-step/index.ts +++ b/web/tutorial-app-generator/src/commands/edit-step/index.ts @@ -4,7 +4,8 @@ import { Command, Option } from "@commander-js/extra-typings"; import { $ } from "zx"; import Enquirer from "enquirer"; -import type { Action } from "../../executeSteps/actions"; +import type { AppDirPath } from "../../brandedTypes"; +import type { Action, ApplyPatchAction } from "../../executeSteps/actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; import { getCommitPatch } from "../../git"; import { log } from "../../log"; @@ -22,36 +23,57 @@ export const editStepCommand = new Command("edit-step") throw new Error(`Step with name "${stepName}" not found.`); } - const fixesBranchName = "fixes"; - await $({ - cwd: appDir, - })`git switch -c ${fixesBranchName} ${action.stepName}`; - - await Enquirer.prompt({ - type: "confirm", - name: "edit", - message: `Apply edit for step "${action.stepName}" and press Enter`, - initial: true, - }); - - await $({ cwd: appDir })`git add .`; - await $({ cwd: appDir })`git commit --amend --no-edit`; - await $({ cwd: appDir })`git tag -f ${action.stepName}`; - await $({ cwd: appDir })`git switch ${mainBranchName}`; - await $({ cwd: appDir, throw: false })`git rebase ${fixesBranchName}`; - - await Enquirer.prompt({ - type: "confirm", - name: "issues", - message: `If there are any rebase issues, resolve them and press Enter to continue`, - initial: true, - }); + if (action.kind !== "apply-patch") { + throw new Error(`Step "${stepName}" is not an editable step.`); + } + + await editStepPatch({ appDir, action }); await extractCommitsIntoPatches(actions); log("success", `Edit completed for step ${action.stepName}!`); }); +async function editStepPatch({ + appDir, + action, +}: { + appDir: AppDirPath; + action: ApplyPatchAction; +}): Promise { + await $({ cwd: appDir })`git switch ${mainBranchName}`.quiet(true); + + const fixesBranchName = "fixes"; + // Clean up any existing fixes branch + await $({ + cwd: appDir, + throw: false, + })`git branch --delete ${fixesBranchName}`; + await $({ + cwd: appDir, + })`git switch -c ${fixesBranchName} ${action.stepName}`.quiet(true); + + await Enquirer.prompt({ + type: "confirm", + name: "edit", + message: `Apply edit for step "${action.stepName}" and press Enter`, + initial: true, + }); + + await $({ cwd: appDir })`git add .`; + await $({ cwd: appDir })`git commit --amend --no-edit`; + await $({ cwd: appDir })`git tag -f ${action.stepName}`; + await $({ cwd: appDir })`git switch ${mainBranchName}`; + await $({ cwd: appDir, throw: false })`git rebase ${fixesBranchName}`; + + await Enquirer.prompt({ + type: "confirm", + name: "issues", + message: `If there are any rebase issues, resolve them and press Enter to continue`, + initial: true, + }); +} + async function extractCommitsIntoPatches(actions: Action[]): Promise { for (const action of actions) { if (action.kind !== "apply-patch") { diff --git a/web/tutorial-app-generator/src/commands/generate-app/index.ts b/web/tutorial-app-generator/src/commands/generate-app/index.ts index a20edc15eb..8daa922ada 100644 --- a/web/tutorial-app-generator/src/commands/generate-app/index.ts +++ b/web/tutorial-app-generator/src/commands/generate-app/index.ts @@ -1,11 +1,12 @@ import { Command } from "@commander-js/extra-typings"; import { $ } from "zx"; +import type { AppDirPath, AppName } from "../../brandedTypes"; import { executeSteps } from "../../executeSteps"; import type { Action } from "../../executeSteps/actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; import { log } from "../../log"; -import { appDir, mainBranchName } from "../../project"; +import { appDir, appName, mainBranchName, patchesDir } from "../../project"; import { waspNew } from "../../waspCli"; export const generateAppCommand = new Command("generate-app") @@ -13,14 +14,20 @@ export const generateAppCommand = new Command("generate-app") .action(async () => { const actions: Action[] = await getActionsFromTutorialFiles(); - await prepareApp(); - await executeSteps(actions); + await prepareApp({ appDir, appName }); + await executeSteps({ appDir, patchesDir, actions }); log("success", "Tutorial app has been successfully generated!"); }); -async function prepareApp() { +async function prepareApp({ + appName, + appDir, +}: { + appDir: AppDirPath; + appName: AppName; +}): Promise { await $`rm -rf ${appDir}`; - await waspNew(appDir); + await waspNew(appName); // Git needs to be initialized for patches to work await $({ cwd: appDir })`git init`.quiet(true); await $({ cwd: appDir })`git branch -m ${mainBranchName}`; diff --git a/web/tutorial-app-generator/src/executeSteps/actions.ts b/web/tutorial-app-generator/src/executeSteps/actions.ts index b0d5b4961a..4fafd885c6 100644 --- a/web/tutorial-app-generator/src/executeSteps/actions.ts +++ b/web/tutorial-app-generator/src/executeSteps/actions.ts @@ -1,4 +1,5 @@ import path from "path"; +import type { PatchContentPath } from "../brandedTypes"; export type ActionCommon = { stepName: string; @@ -7,7 +8,7 @@ export type ActionCommon = { export type ApplyPatchAction = { kind: "apply-patch"; - patchContentPath: string; + patchContentPath: PatchContentPath; } & ActionCommon; export type MigrateDbAction = { @@ -27,7 +28,7 @@ export function createApplyPatchAction( "../docs/tutorial", "patches", `${markdownFileWithoutExt}__${commonActionData.stepName}.patch`, - ); + ) as PatchContentPath; return { ...commonActionData, diff --git a/web/tutorial-app-generator/src/executeSteps/index.ts b/web/tutorial-app-generator/src/executeSteps/index.ts index dd5b8ff4fb..c2a4eba7f2 100644 --- a/web/tutorial-app-generator/src/executeSteps/index.ts +++ b/web/tutorial-app-generator/src/executeSteps/index.ts @@ -1,14 +1,22 @@ import { chalk } from "zx"; +import type { AppDirPath, PatchesDirPath } from "../brandedTypes"; import { ensureDirExists } from "../files"; import { tagChanges } from "../git"; import { log } from "../log"; -import { appDir, patchesDir } from "../project"; import { waspDbMigrate } from "../waspCli"; import { type Action } from "./actions"; import { applyPatch, tryToFixPatch } from "./patch"; -export async function executeSteps(actions: Action[]): Promise { +export async function executeSteps({ + appDir, + patchesDir, + actions, +}: { + appDir: AppDirPath; + patchesDir: PatchesDirPath; + actions: Action[]; +}): Promise { for (const action of actions) { const kind = action.kind; log("info", `${chalk.bold(`[step ${action.stepName}]`)} ${kind}`); diff --git a/web/tutorial-app-generator/src/executeSteps/patch.ts b/web/tutorial-app-generator/src/executeSteps/patch.ts index 9d4d3c54f0..80c2496715 100644 --- a/web/tutorial-app-generator/src/executeSteps/patch.ts +++ b/web/tutorial-app-generator/src/executeSteps/patch.ts @@ -2,21 +2,24 @@ import fs from "fs/promises"; import path from "path"; import Enquirer from "enquirer"; -import { $ } from "zx"; - import parseGitDiff from "parse-git-diff"; +import { $ } from "zx"; +import type { AppDirPath, PatchContentPath } from "../brandedTypes"; import { doesFileExist } from "../files"; import { generatePatchFromChanges } from "../git"; import { log } from "../log"; import type { ApplyPatchAction } from "./actions"; -export async function applyPatch(appDir: string, patchContentPath: string) { +export async function applyPatch( + appDir: AppDirPath, + patchContentPath: PatchContentPath, +) { await $({ cwd: appDir })`git apply ${patchContentPath} --verbose`.quiet(true); } export async function tryToFixPatch( - appDir: string, + appDir: AppDirPath, action: ApplyPatchAction, ): Promise { log("info", `Trying to fix patch for step: ${action.stepName}`); @@ -27,13 +30,16 @@ export async function tryToFixPatch( await fs.unlink(patchPath); } - await createPatchForStep(appDir, action); + await createPatchForStep({ appDir, action }); } -export async function createPatchForStep( - appDir: string, - action: ApplyPatchAction, -) { +export async function createPatchForStep({ + appDir, + action, +}: { + appDir: AppDirPath; + action: ApplyPatchAction; +}) { await Enquirer.prompt({ type: "confirm", name: "edit", diff --git a/web/tutorial-app-generator/src/extractSteps/customHeadingId.ts b/web/tutorial-app-generator/src/extractSteps/customHeadingId.ts deleted file mode 100644 index ffa5a133ee..0000000000 --- a/web/tutorial-app-generator/src/extractSteps/customHeadingId.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Options } from "mdast-util-to-markdown"; - -declare module "mdast" { - interface IdString extends Node { - type: "idString"; - value: string; - } - - interface RootContentMap { - idString: IdString; - } -} - -export const customHeadingId: Options = { - handlers: { - idString: (node) => `{#${node.value}}`, - }, -}; diff --git a/web/tutorial-app-generator/src/extractSteps/index.ts b/web/tutorial-app-generator/src/extractSteps/index.ts index 394d36de62..43dad4c431 100644 --- a/web/tutorial-app-generator/src/extractSteps/index.ts +++ b/web/tutorial-app-generator/src/extractSteps/index.ts @@ -12,6 +12,7 @@ import { type Action, type ActionCommon, } from "../executeSteps/actions.js"; +import { log } from "../log.js"; const componentName = "TutorialAction"; @@ -29,12 +30,12 @@ export async function getActionsFromTutorialFiles(): Promise { ); const actions: Action[] = []; for (const file of files) { - console.log(`Processing file: ${file}`); const fileActions = await getActionsFromMarkdownFile( path.resolve("../docs/tutorial", file), ); actions.push(...fileActions); } + log("success", `Found ${actions.length} actions in tutorial files.`); return actions; } diff --git a/web/tutorial-app-generator/src/files.ts b/web/tutorial-app-generator/src/files.ts index fa6b86b908..6e6582c6be 100644 --- a/web/tutorial-app-generator/src/files.ts +++ b/web/tutorial-app-generator/src/files.ts @@ -1,7 +1,7 @@ import fs from "fs/promises"; -export async function ensureDirExists(dir: string): Promise { - await fs.mkdir(dir, { recursive: true }); +export async function ensureDirExists(dirPath: string): Promise { + await fs.mkdir(dirPath, { recursive: true }); } export async function doesFileExist(filePath: string): Promise { diff --git a/web/tutorial-app-generator/src/project.ts b/web/tutorial-app-generator/src/project.ts index a2eb8d4bab..ca7a991886 100644 --- a/web/tutorial-app-generator/src/project.ts +++ b/web/tutorial-app-generator/src/project.ts @@ -1,3 +1,10 @@ -export const appDir = "TodoApp"; -export const patchesDir = "patches"; +import path from "path"; +import type { AppDirPath, AppName, PatchesDirPath } from "./brandedTypes"; + +export const appName = "TodoApp" as AppName; +export const appDir = path.resolve(".", appName) as AppDirPath; +export const patchesDir = path.resolve( + "../docs/tutorial", + "patches", +) as PatchesDirPath; export const mainBranchName = "main"; diff --git a/web/tutorial-app-generator/src/typeUtils.ts b/web/tutorial-app-generator/src/typeUtils.ts new file mode 100644 index 0000000000..5efc5a99b2 --- /dev/null +++ b/web/tutorial-app-generator/src/typeUtils.ts @@ -0,0 +1,4 @@ +declare const __brand: unique symbol; +type Brand = { [__brand]: B }; + +export type Branded = T & Brand; diff --git a/web/tutorial-app-generator/src/waspCli.ts b/web/tutorial-app-generator/src/waspCli.ts index c8beae5acc..25c0196feb 100644 --- a/web/tutorial-app-generator/src/waspCli.ts +++ b/web/tutorial-app-generator/src/waspCli.ts @@ -1,7 +1,8 @@ import { $ } from "zx"; +import type { AppDirPath, AppName } from "./brandedTypes"; export async function waspDbMigrate( - appDir: string, + appDir: AppDirPath, migrationName: string, ): Promise { await $({ @@ -11,6 +12,6 @@ export async function waspDbMigrate( })`wasp db migrate-dev --name ${migrationName}`; } -export async function waspNew(appDir: string): Promise { - await $`wasp new ${appDir} -t minimal`; +export async function waspNew(appName: AppName): Promise { + await $`wasp new ${appName} -t minimal`; } From c20ea088026b12c7284559e629e640449cc77522 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 18 Jul 2025 11:03:47 +0200 Subject: [PATCH 18/48] Revert change to search-and-replace.ts Signed-off-by: Mihovil Ilakovac --- web/src/remark/search-and-replace.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/src/remark/search-and-replace.ts b/web/src/remark/search-and-replace.ts index b59f709cd6..7efb242057 100644 --- a/web/src/remark/search-and-replace.ts +++ b/web/src/remark/search-and-replace.ts @@ -12,7 +12,7 @@ const replacements = [ }, ]; -export function visitor(tree: Root) { +const plugin: Plugin<[], Root> = () => (tree) => { visit(tree, (node) => { // NOTE: For now we only replace in code blocks to keep // the search and replace logic simple. @@ -22,8 +22,6 @@ export function visitor(tree: Root) { } } }); -} - -const plugin: Plugin<[], Root> = () => visitor; +}; export default plugin; From 1348c8946b7197286800900eb73855bb3cb7e820 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 18 Jul 2025 11:11:17 +0200 Subject: [PATCH 19/48] Cleanup TutorialAction --- web/docs/tutorial/TutorialAction.tsx | 68 ++++++++++++++++++---------- web/tailwind.config.js | 5 +- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index c8584900eb..d7c266c2f7 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -1,35 +1,23 @@ -import React from "react"; - -type Action = "apply-patch" | "write" | "migrate-db"; +type Action = "apply-patch" | "migrate-db"; /* - This component serves two purposes: 1. It provides metadata for the `tutorial-app-generator` on how to execute tutorial steps programmatically. -2. It renders tutorial steps in a visually distinct way during development so it's easier to debug. - +2. It renders tutorial step names during development for easier debugging. */ export function TutorialAction({ - children, action, step, -}: React.PropsWithChildren<{ +}: { action: Action; step: string; -}>) { - return process.env.NODE_ENV === "production" ? ( - children - ) : action === "write" ? ( -
-
+}) { + return ( + process.env.NODE_ENV !== "production" && ( +
- {children} -
- ) : ( -
- -
+ ) ); } @@ -41,17 +29,47 @@ function TutorialActionStep({ action: Action; }) { return ( -
-
+
+
tutorial action: {action}
{ - navigator.clipboard.writeText(step); + style={{ + borderRadius: "0.25rem", + backgroundColor: "#ef4444", + paddingLeft: "0.5rem", + paddingRight: "0.5rem", + paddingTop: "0.25rem", + paddingBottom: "0.25rem", + fontSize: "0.75rem", + fontWeight: "bold", + color: "white", + display: "flex", + alignItems: "center", + gap: "0.25rem", }} > {step} + { + navigator.clipboard.writeText(step); + }} + > + [copy] +
); diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 869632cd23..a31688cbe0 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,9 +1,6 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: [ - "./src/**/*.{js,jsx,ts,tsx}", - "./docs/tutorial/**/*.{js,jsx,ts,tsx}", - ], + content: ["./src/**/*.{js,jsx,ts,tsx}"], important: true, corePlugins: { preflight: false, From 69149ae79410020ea67d52718848c3f3714bdfd5 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 18 Jul 2025 12:44:43 +0200 Subject: [PATCH 20/48] Cleanup --- web/tutorial-app-generator/.gitignore | 1 - web/tutorial-app-generator/README.md | 64 ++++++++--- .../src/brandedTypes.ts | 5 +- .../src/commands/edit-step/index.ts | 26 ++--- .../src/commands/generate-app/index.ts | 21 ++-- .../src/executeSteps/actions.ts | 46 +++++--- .../src/executeSteps/index.ts | 17 ++- .../src/executeSteps/patch.ts | 46 ++++---- .../src/extractSteps/index.ts | 105 ++++++++++-------- web/tutorial-app-generator/src/files.ts | 5 + web/tutorial-app-generator/src/git.ts | 36 ++++-- web/tutorial-app-generator/src/index.ts | 2 +- web/tutorial-app-generator/src/project.ts | 10 +- 13 files changed, 240 insertions(+), 144 deletions(-) diff --git a/web/tutorial-app-generator/.gitignore b/web/tutorial-app-generator/.gitignore index 5d545e6467..72335df6a5 100644 --- a/web/tutorial-app-generator/.gitignore +++ b/web/tutorial-app-generator/.gitignore @@ -1,3 +1,2 @@ node_modules/ TodoApp/ -patches/ diff --git a/web/tutorial-app-generator/README.md b/web/tutorial-app-generator/README.md index a22af5e554..cfe2a433f6 100644 --- a/web/tutorial-app-generator/README.md +++ b/web/tutorial-app-generator/README.md @@ -1,34 +1,64 @@ # Tutorial App Generator -Generates a Wasp app by executing tutorial steps from markdown files. +CLI tool that generates tutorial apps by parsing markdown files and applying patches. + +## Setup + +```bash +npm install +``` ## Usage +### Generate Tutorial App + +Parses tutorial markdown files and generates a complete app with all steps applied: + ```bash -npm run start +npm start generate-app ``` -## Options +This will: + +1. Create a new Wasp app in `./TodoApp` +1. Initialize git repo with tagged commits +1. Parse tutorial files from `../docs/tutorial` +1. Apply patches for each tutorial step -- `-s, --until-step ` - Run until the given step number -- `-e, --broken-diff ` - Edit mode for fixing diffs interactively +### Edit Tutorial Step -## Examples +Edit an existing tutorial step and regenerate patches: ```bash -# Run all steps -npm run start +npm start edit-step --step-name +``` + +This will: + +1. Switch to the step's git tag +1. Open interactive edit session +1. Update the patch file +1. Rebase subsequent commits + +Make sure you first run the `generate-app` command to create the initial app structure. + +## File Structure + +- `src/extractSteps/` - Parses markdown files for `` components +- `src/executeSteps/` - Applies patches and runs migrations +- `src/commands/` - CLI command implementations +- `../docs/tutorial` - Generated patch files for each tutorial step + +## Tutorial Markdown Format -# Run until step 5 -npm run start -- -s 5 +Use `` components in markdown: -# Edit diff at step 3 -npm run start -- -e 3 +```jsx + + ``` -## What it does +Supported actions: -1. Creates a new Wasp app in the output directory -2. Parses tutorial markdown files to extract steps -3. Executes diff actions and other steps sequentially -4. Supports interactive editing of broken diffs +- `apply-patch` - Applies a git patch file +- `migrate-db diff --git a/web/tutorial-app-generator/src/brandedTypes.ts b/web/tutorial-app-generator/src/brandedTypes.ts index 5b6ffa4728..c839429189 100644 --- a/web/tutorial-app-generator/src/brandedTypes.ts +++ b/web/tutorial-app-generator/src/brandedTypes.ts @@ -3,4 +3,7 @@ import type { Branded } from "./typeUtils"; export type AppName = Branded; export type AppDirPath = Branded; export type PatchesDirPath = Branded; -export type PatchContentPath = Branded; +export type TutorialDirPath = Branded; +export type PatchFilePath = Branded; +export type StepName = Branded; +export type MarkdownFilePath = Branded; diff --git a/web/tutorial-app-generator/src/commands/edit-step/index.ts b/web/tutorial-app-generator/src/commands/edit-step/index.ts index 58cd0c0eef..cf64dd10a7 100644 --- a/web/tutorial-app-generator/src/commands/edit-step/index.ts +++ b/web/tutorial-app-generator/src/commands/edit-step/index.ts @@ -7,15 +7,15 @@ import Enquirer from "enquirer"; import type { AppDirPath } from "../../brandedTypes"; import type { Action, ApplyPatchAction } from "../../executeSteps/actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; -import { getCommitPatch } from "../../git"; +import { generatePatchFromRevision } from "../../git"; import { log } from "../../log"; -import { appDir, mainBranchName } from "../../project"; +import { appDir, mainBranchName, tutorialDir } from "../../project"; export const editStepCommand = new Command("edit-step") .description("Edit a step in the tutorial app") .addOption(new Option("--step-name ", "Name of the step to edit")) .action(async ({ stepName }) => { - const actions: Action[] = await getActionsFromTutorialFiles(); + const actions: Action[] = await getActionsFromTutorialFiles(tutorialDir); const action = actions.find((a) => a.stepName === stepName); @@ -44,14 +44,10 @@ async function editStepPatch({ await $({ cwd: appDir })`git switch ${mainBranchName}`.quiet(true); const fixesBranchName = "fixes"; - // Clean up any existing fixes branch await $({ cwd: appDir, - throw: false, - })`git branch --delete ${fixesBranchName}`; - await $({ - cwd: appDir, - })`git switch -c ${fixesBranchName} ${action.stepName}`.quiet(true); + quiet: true, + })`git switch --force-create ${fixesBranchName} ${action.stepName}`; await Enquirer.prompt({ type: "confirm", @@ -75,13 +71,13 @@ async function editStepPatch({ } async function extractCommitsIntoPatches(actions: Action[]): Promise { - for (const action of actions) { - if (action.kind !== "apply-patch") { - continue; - } + const applyPatchActions = actions.filter( + (action) => action.kind === "apply-patch", + ); + for (const action of applyPatchActions) { log("info", `Updating patch for step ${action.stepName}`); - const patch = await getCommitPatch(appDir, action.stepName); - await fs.writeFile(action.patchContentPath, patch, "utf-8"); + const patch = await generatePatchFromRevision(appDir, action.stepName); + await fs.writeFile(action.patchFilePath, patch, "utf-8"); } } diff --git a/web/tutorial-app-generator/src/commands/generate-app/index.ts b/web/tutorial-app-generator/src/commands/generate-app/index.ts index 8daa922ada..c07ac68d71 100644 --- a/web/tutorial-app-generator/src/commands/generate-app/index.ts +++ b/web/tutorial-app-generator/src/commands/generate-app/index.ts @@ -5,16 +5,23 @@ import type { AppDirPath, AppName } from "../../brandedTypes"; import { executeSteps } from "../../executeSteps"; import type { Action } from "../../executeSteps/actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; +import { initGitRepo } from "../../git"; import { log } from "../../log"; -import { appDir, appName, mainBranchName, patchesDir } from "../../project"; +import { + appDir, + appName, + mainBranchName, + patchesDir, + tutorialDir, +} from "../../project"; import { waspNew } from "../../waspCli"; export const generateAppCommand = new Command("generate-app") .description("Generate a new Wasp app based on the tutorial steps") .action(async () => { - const actions: Action[] = await getActionsFromTutorialFiles(); + const actions: Action[] = await getActionsFromTutorialFiles(tutorialDir); - await prepareApp({ appDir, appName }); + await prepareApp({ appDir, appName, mainBranchName }); await executeSteps({ appDir, patchesDir, actions }); log("success", "Tutorial app has been successfully generated!"); }); @@ -22,16 +29,14 @@ export const generateAppCommand = new Command("generate-app") async function prepareApp({ appName, appDir, + mainBranchName, }: { appDir: AppDirPath; appName: AppName; + mainBranchName: string; }): Promise { await $`rm -rf ${appDir}`; await waspNew(appName); - // Git needs to be initialized for patches to work - await $({ cwd: appDir })`git init`.quiet(true); - await $({ cwd: appDir })`git branch -m ${mainBranchName}`; - await $({ cwd: appDir })`git add .`; - await $({ cwd: appDir })`git commit -m "Initial commit"`; + await initGitRepo(appDir, mainBranchName); log("info", "Tutorial app directory has been initialized"); } diff --git a/web/tutorial-app-generator/src/executeSteps/actions.ts b/web/tutorial-app-generator/src/executeSteps/actions.ts index 4fafd885c6..d9ebe59161 100644 --- a/web/tutorial-app-generator/src/executeSteps/actions.ts +++ b/web/tutorial-app-generator/src/executeSteps/actions.ts @@ -1,14 +1,20 @@ import path from "path"; -import type { PatchContentPath } from "../brandedTypes"; +import type { + MarkdownFilePath, + PatchFilePath, + StepName, +} from "../brandedTypes"; +import { getFileNameWithoutExtension } from "../files"; +import { patchesDir } from "../project"; export type ActionCommon = { - stepName: string; - markdownSourceFilePath: string; + stepName: StepName; + sourceFilePath: MarkdownFilePath; }; export type ApplyPatchAction = { kind: "apply-patch"; - patchContentPath: PatchContentPath; + patchFilePath: PatchFilePath; } & ActionCommon; export type MigrateDbAction = { @@ -17,22 +23,28 @@ export type MigrateDbAction = { export type Action = ApplyPatchAction | MigrateDbAction; +export function createMigrateDbAction( + commonData: ActionCommon, +): MigrateDbAction { + return { + ...commonData, + kind: "migrate-db", + }; +} + export function createApplyPatchAction( - commonActionData: ActionCommon, + commonData: ActionCommon, ): ApplyPatchAction { - const markdownFileWithoutExt = path.basename( - commonActionData.markdownSourceFilePath, - path.extname(commonActionData.markdownSourceFilePath), - ); - const patchContentPath = path.resolve( - "../docs/tutorial", - "patches", - `${markdownFileWithoutExt}__${commonActionData.stepName}.patch`, - ) as PatchContentPath; - + const patchFilePath = getPatchFilePath(commonData); return { - ...commonActionData, + ...commonData, kind: "apply-patch", - patchContentPath, + patchFilePath, }; } + +function getPatchFilePath(action: ActionCommon): PatchFilePath { + const sourceFileName = getFileNameWithoutExtension(action.sourceFilePath); + const patchFileName = `${sourceFileName}__${action.stepName}.patch`; + return path.resolve(patchesDir, patchFileName) as PatchFilePath; +} diff --git a/web/tutorial-app-generator/src/executeSteps/index.ts b/web/tutorial-app-generator/src/executeSteps/index.ts index c2a4eba7f2..b758bfc8df 100644 --- a/web/tutorial-app-generator/src/executeSteps/index.ts +++ b/web/tutorial-app-generator/src/executeSteps/index.ts @@ -2,7 +2,7 @@ import { chalk } from "zx"; import type { AppDirPath, PatchesDirPath } from "../brandedTypes"; import { ensureDirExists } from "../files"; -import { tagChanges } from "../git"; +import { tagAllChanges } from "../git"; import { log } from "../log"; import { waspDbMigrate } from "../waspCli"; import { type Action } from "./actions"; @@ -18,34 +18,33 @@ export async function executeSteps({ actions: Action[]; }): Promise { for (const action of actions) { - const kind = action.kind; - log("info", `${chalk.bold(`[step ${action.stepName}]`)} ${kind}`); + log("info", `${chalk.bold(`[step ${action.stepName}]`)} ${action.kind}`); await ensureDirExists(patchesDir); try { - switch (kind) { + switch (action.kind) { case "apply-patch": try { - await applyPatch(appDir, action.patchContentPath); + await applyPatch({ appDir, action }); } catch (err) { log( "error", `Failed to apply patch for step ${action.stepName}:\n${err}`, ); - await tryToFixPatch(appDir, action); + await tryToFixPatch({ appDir, action }); } break; case "migrate-db": - await waspDbMigrate(appDir, `step-${action.stepName}`); + await waspDbMigrate(appDir, action.stepName); break; default: - kind satisfies never; + action satisfies never; } } catch (err) { log("error", `Error in step ${action.stepName}:\n\n${err}`); process.exit(1); } - await tagChanges(appDir, action.stepName); + await tagAllChanges(appDir, action.stepName); } } diff --git a/web/tutorial-app-generator/src/executeSteps/patch.ts b/web/tutorial-app-generator/src/executeSteps/patch.ts index 80c2496715..2be498fa84 100644 --- a/web/tutorial-app-generator/src/executeSteps/patch.ts +++ b/web/tutorial-app-generator/src/executeSteps/patch.ts @@ -1,33 +1,39 @@ import fs from "fs/promises"; -import path from "path"; import Enquirer from "enquirer"; import parseGitDiff from "parse-git-diff"; import { $ } from "zx"; -import type { AppDirPath, PatchContentPath } from "../brandedTypes"; +import type { AppDirPath } from "../brandedTypes"; import { doesFileExist } from "../files"; -import { generatePatchFromChanges } from "../git"; +import { generatePatchFromAllChanges } from "../git"; import { log } from "../log"; import type { ApplyPatchAction } from "./actions"; -export async function applyPatch( - appDir: AppDirPath, - patchContentPath: PatchContentPath, -) { - await $({ cwd: appDir })`git apply ${patchContentPath} --verbose`.quiet(true); +export async function applyPatch({ + appDir, + action, +}: { + appDir: AppDirPath; + action: ApplyPatchAction; +}) { + await $({ cwd: appDir })`git apply ${action.patchFilePath} --verbose`.quiet( + true, + ); } -export async function tryToFixPatch( - appDir: AppDirPath, - action: ApplyPatchAction, -): Promise { +export async function tryToFixPatch({ + appDir, + action, +}: { + appDir: AppDirPath; + action: ApplyPatchAction; +}): Promise { log("info", `Trying to fix patch for step: ${action.stepName}`); - const patchPath = path.resolve(appDir, action.patchContentPath); - if (await doesFileExist(patchPath)) { - log("info", `Removing existing patch file: ${patchPath}`); - await fs.unlink(patchPath); + if (await doesFileExist(action.patchFilePath)) { + log("info", `Removing existing patch file: ${action.patchFilePath}`); + await fs.unlink(action.patchFilePath); } await createPatchForStep({ appDir, action }); @@ -46,10 +52,12 @@ export async function createPatchForStep({ message: `Apply edit for ${action.stepName} and press Enter`, initial: true, }); - const patch = await generatePatchFromChanges(appDir); + + const patch = await generatePatchFromAllChanges(appDir); assertValidPatch(patch); - await fs.writeFile(action.patchContentPath, patch, "utf-8"); - log("info", `Patch file created: ${action.patchContentPath}`); + await fs.writeFile(action.patchFilePath, patch, "utf-8"); + + log("info", `Patch file created: ${action.patchFilePath}`); } export async function assertValidPatch(patch: string): Promise { diff --git a/web/tutorial-app-generator/src/extractSteps/index.ts b/web/tutorial-app-generator/src/extractSteps/index.ts index 43dad4c431..bf2e932c68 100644 --- a/web/tutorial-app-generator/src/extractSteps/index.ts +++ b/web/tutorial-app-generator/src/extractSteps/index.ts @@ -7,83 +7,98 @@ import { mdxJsxFromMarkdown, type MdxJsxFlowElement } from "mdast-util-mdx-jsx"; import { mdxJsx } from "micromark-extension-mdx-jsx"; import { visit } from "unist-util-visit"; +import type { + MarkdownFilePath, + StepName, + TutorialDirPath, +} from "../brandedTypes.js"; import { createApplyPatchAction, + createMigrateDbAction, type Action, type ActionCommon, } from "../executeSteps/actions.js"; import { log } from "../log.js"; -const componentName = "TutorialAction"; - -export async function getActionsFromTutorialFiles(): Promise { - const files = await fs - .readdir(path.resolve("../docs/tutorial")) - .then((files) => - files - .filter((file) => file.endsWith(".md")) - .sort((a, b) => { - const aNumber = parseInt(a.split("-")[0]!, 10); - const bNumber = parseInt(b.split("-")[0]!, 10); - return aNumber - bNumber; - }), - ); +export async function getActionsFromTutorialFiles( + tutorialDir: TutorialDirPath, +): Promise { + const tutorialFilePaths = await getTutorialFilePaths(tutorialDir); const actions: Action[] = []; - for (const file of files) { - const fileActions = await getActionsFromMarkdownFile( - path.resolve("../docs/tutorial", file), - ); + + for (const filePath of tutorialFilePaths) { + const fileActions = await getActionsFromMarkdownFile(filePath); actions.push(...fileActions); } + log("success", `Found ${actions.length} actions in tutorial files.`); return actions; } -async function getActionsFromMarkdownFile(filePath: string): Promise { - const actions = [] as Action[]; - const doc = await fs.readFile(path.resolve(filePath)); +async function getTutorialFilePaths( + tutorialDir: TutorialDirPath, +): Promise { + const files = await fs.readdir(tutorialDir); + return ( + files + .filter((file) => file.endsWith(".md")) + // Tutorial files are named "01-something.md" + // and we want to sort them by the number prefix + .sort((a, b) => { + const aNumber = parseInt(a.split("-")[0]!, 10); + const bNumber = parseInt(b.split("-")[0]!, 10); + return aNumber - bNumber; + }) + .map((file) => path.resolve(tutorialDir, file) as MarkdownFilePath) + ); +} + +async function getActionsFromMarkdownFile( + sourceFilePath: MarkdownFilePath, +): Promise { + const actions: Action[] = []; + const fileContent = await fs.readFile(path.resolve(sourceFilePath)); - const ast = fromMarkdown(doc, { + const ast = fromMarkdown(fileContent, { extensions: [mdxJsx({ acorn, addResult: true })], mdastExtensions: [mdxJsxFromMarkdown()], }); + const tutorialComponentName = "TutorialAction"; visit(ast, "mdxJsxFlowElement", (node) => { - if (node.name !== componentName) { + if (node.name !== tutorialComponentName) { return; } - const step = getAttributeValue(node, "step"); - const action = getAttributeValue(node, "action"); + const stepName = getAttributeValue(node, "step") as StepName | null; + const actionName = getAttributeValue(node, "action"); - if (!step || !action) { + if (!stepName || !actionName) { throw new Error("Step and action attributes are required"); } - const commonActionData: ActionCommon = { - stepName: step, - markdownSourceFilePath: filePath, - }; - - if (action === "migrate-db") { - actions.push({ - ...commonActionData, - kind: "migrate-db", - }); - return; - } else if (action === "apply-patch") { - const patchAction = createApplyPatchAction(commonActionData); - actions.push(patchAction); - return; - } - - throw new Error( - `Unknown action type: ${action} in file: ${filePath}, step: ${step}`, + actions.push( + createAction(actionName, { + stepName, + sourceFilePath, + }), ); }); return actions; } +function createAction(actionName: string, commonData: ActionCommon): Action { + const actionCreators: Record Action> = { + "apply-patch": createApplyPatchAction, + "migrate-db": createMigrateDbAction, + }; + const createFn = actionCreators[actionName]; + if (!createFn) { + throw new Error(`Unknown action type: ${actionName}`); + } + return createFn(commonData); +} + function getAttributeValue( node: MdxJsxFlowElement, attributeName: string, diff --git a/web/tutorial-app-generator/src/files.ts b/web/tutorial-app-generator/src/files.ts index 6e6582c6be..166eacb2e4 100644 --- a/web/tutorial-app-generator/src/files.ts +++ b/web/tutorial-app-generator/src/files.ts @@ -1,4 +1,5 @@ import fs from "fs/promises"; +import path from "path"; export async function ensureDirExists(dirPath: string): Promise { await fs.mkdir(dirPath, { recursive: true }); @@ -12,3 +13,7 @@ export async function doesFileExist(filePath: string): Promise { return false; } } + +export function getFileNameWithoutExtension(filePath: string): string { + return path.basename(filePath, path.extname(filePath)); +} diff --git a/web/tutorial-app-generator/src/git.ts b/web/tutorial-app-generator/src/git.ts index 8c37dd7dab..31efbb3d8f 100644 --- a/web/tutorial-app-generator/src/git.ts +++ b/web/tutorial-app-generator/src/git.ts @@ -2,26 +2,34 @@ import { $ } from "zx"; import { log } from "./log"; -export async function tagChanges(gitRepoDir: string, tagName: string) { - await $({ cwd: gitRepoDir })`git add .`; - await $({ cwd: gitRepoDir })`git commit -m "${tagName}"`; +export async function tagAllChanges(gitRepoDir: string, tagName: string) { + await commitAllChanges(gitRepoDir, `Changes for tag: ${tagName}`); await $({ cwd: gitRepoDir })`git tag ${tagName}`; log("info", `Tagged changes with "${tagName}"`); } -export async function generatePatchFromChanges( +export async function generatePatchFromAllChanges( gitRepoDir: string, ): Promise { - await $({ cwd: gitRepoDir })`git add .`; - await $({ cwd: gitRepoDir })`git commit -m "temporary-commit"`; + await commitAllChanges(gitRepoDir, "temporary-commit"); + const patch = await generatePatchFromRevision(gitRepoDir, "HEAD"); + await removeLastCommit(gitRepoDir); + return patch; +} - const patch = await getCommitPatch(gitRepoDir, "HEAD"); +async function commitAllChanges( + gitRepoDir: string, + message: string, +): Promise { + await $({ cwd: gitRepoDir })`git add .`; + await $({ cwd: gitRepoDir })`git commit -m "${message}"`; +} +async function removeLastCommit(gitRepoDir: string): Promise { await $({ cwd: gitRepoDir })`git reset --hard HEAD~1`; - return patch; } -export async function getCommitPatch( +export async function generatePatchFromRevision( gitRepoDir: string, gitRevision: string, ): Promise { @@ -31,3 +39,13 @@ export async function getCommitPatch( return patch; } + +export async function initGitRepo( + gitRepoDir: string, + mainBranchName: string, +): Promise { + await $({ cwd: gitRepoDir })`git init`.quiet(true); + await $({ cwd: gitRepoDir })`git branch -m ${mainBranchName}`; + await $({ cwd: gitRepoDir })`git add .`; + await $({ cwd: gitRepoDir })`git commit -m "Initial commit"`; +} diff --git a/web/tutorial-app-generator/src/index.ts b/web/tutorial-app-generator/src/index.ts index ec9e939f05..a58a7ecf0e 100644 --- a/web/tutorial-app-generator/src/index.ts +++ b/web/tutorial-app-generator/src/index.ts @@ -2,7 +2,7 @@ import { program } from "@commander-js/extra-typings"; import { editStepCommand } from "./commands/edit-step"; import { generateAppCommand } from "./commands/generate-app"; -const _args = program +program .addCommand(generateAppCommand) .addCommand(editStepCommand) .parse(process.argv) diff --git a/web/tutorial-app-generator/src/project.ts b/web/tutorial-app-generator/src/project.ts index ca7a991886..25fb148969 100644 --- a/web/tutorial-app-generator/src/project.ts +++ b/web/tutorial-app-generator/src/project.ts @@ -1,10 +1,16 @@ import path from "path"; -import type { AppDirPath, AppName, PatchesDirPath } from "./brandedTypes"; +import type { + AppDirPath, + AppName, + PatchesDirPath, + TutorialDirPath, +} from "./brandedTypes"; export const appName = "TodoApp" as AppName; export const appDir = path.resolve(".", appName) as AppDirPath; +export const tutorialDir = path.resolve("../docs/tutorial") as TutorialDirPath; export const patchesDir = path.resolve( - "../docs/tutorial", + tutorialDir, "patches", ) as PatchesDirPath; export const mainBranchName = "main"; From d981153b7fbf901593a5b3e2e69f1454831ebe05 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 18 Jul 2025 12:55:48 +0200 Subject: [PATCH 21/48] Update styles --- web/docs/tutorial/TutorialAction.tsx | 62 ++++++++++++++-------------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index d7c266c2f7..5c6bcda37e 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -30,40 +30,14 @@ function TutorialActionStep({ }) { return (
-
- tutorial action: {action} -
-
+
tutorial action: {action}
+
{step} { navigator.clipboard.writeText(step); }} @@ -74,3 +48,27 @@ function TutorialActionStep({
); } + +const pillStyle: React.CSSProperties = { + borderRadius: "0.25rem", + paddingLeft: "0.5rem", + paddingRight: "0.5rem", + paddingTop: "0.25rem", + paddingBottom: "0.25rem", + fontSize: "0.75rem", + fontWeight: "bold", + color: "white", +}; + +const tutorialActionPillStyle: React.CSSProperties = { + ...pillStyle, + backgroundColor: "#6b7280", +}; + +const stepPillStyle: React.CSSProperties = { + ...pillStyle, + backgroundColor: "#ef4444", + display: "flex", + alignItems: "center", + gap: "0.25rem", +}; From 66b0fa81cc290f2668988473662f917f1444acfd Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 31 Jul 2025 16:57:34 +0200 Subject: [PATCH 22/48] Fixes patch fixing bug. Update patches. --- .../patches/03-pages__prepare-project.patch | 16 ---------------- .../patches/07-auth__wasp-file-auth.patch | 14 +++++++------- web/tutorial-app-generator/.gitignore | 2 +- web/tutorial-app-generator/src/brandedTypes.ts | 1 + .../src/commands/generate-app/index.ts | 13 ++++++++----- .../src/executeSteps/index.ts | 1 + .../src/executeSteps/patch.ts | 2 ++ web/tutorial-app-generator/src/project.ts | 6 +++++- web/tutorial-app-generator/src/waspCli.ts | 14 +++++++++++--- 9 files changed, 36 insertions(+), 33 deletions(-) diff --git a/web/docs/tutorial/patches/03-pages__prepare-project.patch b/web/docs/tutorial/patches/03-pages__prepare-project.patch index c12bcdfbdb..09902d6e10 100644 --- a/web/docs/tutorial/patches/03-pages__prepare-project.patch +++ b/web/docs/tutorial/patches/03-pages__prepare-project.patch @@ -1,19 +1,3 @@ -diff --git a/main.wasp b/main.wasp -index 1b1bd08..a5f1899 100644 ---- a/main.wasp -+++ b/main.wasp -@@ -2,10 +2,7 @@ app TodoApp { - wasp: { - version: "^0.17.0" - }, -- title: "TodoApp", -- head: [ -- "", -- ] -+ title: "TodoApp" - } - - route RootRoute { path: "/", to: MainPage } diff --git a/src/Main.css b/src/Main.css deleted file mode 100644 index 9e93c7a..0000000 diff --git a/web/docs/tutorial/patches/07-auth__wasp-file-auth.patch b/web/docs/tutorial/patches/07-auth__wasp-file-auth.patch index 344a319ac9..38b0ae4d48 100644 --- a/web/docs/tutorial/patches/07-auth__wasp-file-auth.patch +++ b/web/docs/tutorial/patches/07-auth__wasp-file-auth.patch @@ -1,13 +1,13 @@ diff --git a/main.wasp b/main.wasp -index eed193e..5592563 100644 +index f761afe..d4798a4 100644 --- a/main.wasp +++ b/main.wasp -@@ -2,7 +2,17 @@ app TodoApp { - wasp: { - version: "^0.17.0" - }, -- title: "TodoApp" -+ title: "TodoApp", +@@ -5,7 +5,17 @@ app TodoApp { + title: "TodoApp", + head: [ + "", +- ] ++ ], + auth: { + // Tells Wasp which entity to use for storing users. + userEntity: User, diff --git a/web/tutorial-app-generator/.gitignore b/web/tutorial-app-generator/.gitignore index 72335df6a5..d5700888a3 100644 --- a/web/tutorial-app-generator/.gitignore +++ b/web/tutorial-app-generator/.gitignore @@ -1,2 +1,2 @@ node_modules/ -TodoApp/ + diff --git a/web/tutorial-app-generator/src/brandedTypes.ts b/web/tutorial-app-generator/src/brandedTypes.ts index c839429189..638a6d4b5a 100644 --- a/web/tutorial-app-generator/src/brandedTypes.ts +++ b/web/tutorial-app-generator/src/brandedTypes.ts @@ -2,6 +2,7 @@ import type { Branded } from "./typeUtils"; export type AppName = Branded; export type AppDirPath = Branded; +export type AppParentDirPath = Branded; export type PatchesDirPath = Branded; export type TutorialDirPath = Branded; export type PatchFilePath = Branded; diff --git a/web/tutorial-app-generator/src/commands/generate-app/index.ts b/web/tutorial-app-generator/src/commands/generate-app/index.ts index c07ac68d71..16281b9caf 100644 --- a/web/tutorial-app-generator/src/commands/generate-app/index.ts +++ b/web/tutorial-app-generator/src/commands/generate-app/index.ts @@ -1,7 +1,7 @@ import { Command } from "@commander-js/extra-typings"; import { $ } from "zx"; -import type { AppDirPath, AppName } from "../../brandedTypes"; +import type { AppDirPath, AppName, AppParentDirPath } from "../../brandedTypes"; import { executeSteps } from "../../executeSteps"; import type { Action } from "../../executeSteps/actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; @@ -10,6 +10,7 @@ import { log } from "../../log"; import { appDir, appName, + appParentDir, mainBranchName, patchesDir, tutorialDir, @@ -21,22 +22,24 @@ export const generateAppCommand = new Command("generate-app") .action(async () => { const actions: Action[] = await getActionsFromTutorialFiles(tutorialDir); - await prepareApp({ appDir, appName, mainBranchName }); + await prepareApp({ appDir, appParentDir, appName, mainBranchName }); await executeSteps({ appDir, patchesDir, actions }); log("success", "Tutorial app has been successfully generated!"); }); async function prepareApp({ appName, + appParentDir, appDir, mainBranchName, }: { - appDir: AppDirPath; appName: AppName; + appParentDir: AppParentDirPath; + appDir: AppDirPath; mainBranchName: string; }): Promise { await $`rm -rf ${appDir}`; - await waspNew(appName); + await waspNew({ appName, appParentDir }); await initGitRepo(appDir, mainBranchName); - log("info", "Tutorial app directory has been initialized"); + log("info", `Tutorial app has been initialized in ${appDir}`); } diff --git a/web/tutorial-app-generator/src/executeSteps/index.ts b/web/tutorial-app-generator/src/executeSteps/index.ts index b758bfc8df..915c244e61 100644 --- a/web/tutorial-app-generator/src/executeSteps/index.ts +++ b/web/tutorial-app-generator/src/executeSteps/index.ts @@ -33,6 +33,7 @@ export async function executeSteps({ `Failed to apply patch for step ${action.stepName}:\n${err}`, ); await tryToFixPatch({ appDir, action }); + await applyPatch({ appDir, action }); } break; case "migrate-db": diff --git a/web/tutorial-app-generator/src/executeSteps/patch.ts b/web/tutorial-app-generator/src/executeSteps/patch.ts index 2be498fa84..8fcaed3910 100644 --- a/web/tutorial-app-generator/src/executeSteps/patch.ts +++ b/web/tutorial-app-generator/src/executeSteps/patch.ts @@ -46,6 +46,8 @@ export async function createPatchForStep({ appDir: AppDirPath; action: ApplyPatchAction; }) { + log("info", "Opening tutorial app in VS Code"); + await $`code ${appDir}`; await Enquirer.prompt({ type: "confirm", name: "edit", diff --git a/web/tutorial-app-generator/src/project.ts b/web/tutorial-app-generator/src/project.ts index 25fb148969..6f0225d01c 100644 --- a/web/tutorial-app-generator/src/project.ts +++ b/web/tutorial-app-generator/src/project.ts @@ -1,13 +1,17 @@ import path from "path"; +import os from "os"; + import type { AppDirPath, AppName, + AppParentDirPath, PatchesDirPath, TutorialDirPath, } from "./brandedTypes"; export const appName = "TodoApp" as AppName; -export const appDir = path.resolve(".", appName) as AppDirPath; +export const appParentDir = path.resolve(os.tmpdir()) as AppParentDirPath; +export const appDir = path.resolve(appParentDir, appName) as AppDirPath; export const tutorialDir = path.resolve("../docs/tutorial") as TutorialDirPath; export const patchesDir = path.resolve( tutorialDir, diff --git a/web/tutorial-app-generator/src/waspCli.ts b/web/tutorial-app-generator/src/waspCli.ts index 25c0196feb..dab1d7e967 100644 --- a/web/tutorial-app-generator/src/waspCli.ts +++ b/web/tutorial-app-generator/src/waspCli.ts @@ -1,5 +1,5 @@ import { $ } from "zx"; -import type { AppDirPath, AppName } from "./brandedTypes"; +import type { AppDirPath, AppName, AppParentDirPath } from "./brandedTypes"; export async function waspDbMigrate( appDir: AppDirPath, @@ -12,6 +12,14 @@ export async function waspDbMigrate( })`wasp db migrate-dev --name ${migrationName}`; } -export async function waspNew(appName: AppName): Promise { - await $`wasp new ${appName} -t minimal`; +export async function waspNew({ + appName, + appParentDir, +}: { + appName: AppName; + appParentDir: AppParentDirPath; +}): Promise { + await $({ + cwd: appParentDir, + })`wasp new ${appName} -t minimal`; } From 8b3fe2343a3ca928aef6a05fc0ffe015893e5608 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 4 Aug 2025 18:17:06 +0200 Subject: [PATCH 23/48] PR comments --- web/docs/tutorial/03-pages.md | 2 +- web/docs/tutorial/04-entities.md | 4 +- web/docs/tutorial/05-queries.md | 6 +- web/docs/tutorial/06-actions.md | 14 +- web/docs/tutorial/07-auth.md | 26 +- web/docs/tutorial/TutorialAction.tsx | 14 +- web/tutorial-app-generator/.gitignore | 2 +- web/tutorial-app-generator/README.md | 10 +- web/tutorial-app-generator/package-lock.json | 810 ++++++++++++++---- web/tutorial-app-generator/package.json | 12 +- .../src/brandedTypes.ts | 2 +- .../src/commands/edit-step/index.ts | 118 ++- .../src/commands/generate-app/index.ts | 13 +- .../src/commands/list-steps/index.ts | 62 ++ .../src/executeSteps/actions.ts | 14 +- .../src/executeSteps/index.ts | 15 +- .../src/executeSteps/patch.ts | 18 +- .../src/extractSteps/index.ts | 20 +- web/tutorial-app-generator/src/files.ts | 14 - web/tutorial-app-generator/src/git.ts | 4 +- web/tutorial-app-generator/src/index.ts | 2 + web/tutorial-app-generator/src/project.ts | 3 +- 22 files changed, 891 insertions(+), 294 deletions(-) create mode 100644 web/tutorial-app-generator/src/commands/list-steps/index.ts diff --git a/web/docs/tutorial/03-pages.md b/web/docs/tutorial/03-pages.md index 4e5e1a86c0..a6a7f9bf3c 100644 --- a/web/docs/tutorial/03-pages.md +++ b/web/docs/tutorial/03-pages.md @@ -97,7 +97,7 @@ Now that you've seen how Wasp deals with Routes and Pages, it's finally time to Start by cleaning up the starter project and removing unnecessary code and files. - + First, remove most of the code from the `MainPage` component: diff --git a/web/docs/tutorial/04-entities.md b/web/docs/tutorial/04-entities.md index 58506b43fe..c13b00a165 100644 --- a/web/docs/tutorial/04-entities.md +++ b/web/docs/tutorial/04-entities.md @@ -11,7 +11,7 @@ Wasp uses Prisma to talk to the database, and you define Entities by defining Pr Since our Todo app is all about tasks, we'll define a Task entity by adding a Task model in the `schema.prisma` file: - + ```prisma title="schema.prisma" // ... @@ -29,7 +29,7 @@ Read more about how Wasp Entities work in the [Entities](../data-model/entities. To update the database schema to include this entity, stop the `wasp start` process, if it's running, and run: - + ```sh wasp db migrate-dev ``` diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index 1b82392eab..4d722d27f7 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -44,7 +44,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis - + ```wasp title="main.wasp" // ... @@ -76,7 +76,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis Next, create a new file called `src/queries.ts` and define the TypeScript function we've just imported in our `query` declaration: - + ```ts title="src/queries.ts" auto-js import type { Task } from "wasp/entities"; @@ -122,7 +122,7 @@ While we implement Queries on the server, Wasp generates client-side functions t This makes it easy for us to use the `getTasks` Query we just created in our React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { Task } from "wasp/entities"; diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index c7b2cf426e..16567d375d 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -23,7 +23,7 @@ Creating an Action is very similar to creating a Query. We must first declare the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -38,7 +38,7 @@ action createTask { Let's now define a JavaScriptTypeScript function for our `createTask` Action: - + ```ts title="src/actions.ts" auto-js import type { Task } from "wasp/entities"; @@ -68,7 +68,7 @@ We put the function in a new file `src/actions.{js,ts}`, but we could have put i Start by defining a form for creating new tasks. - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from "react"; @@ -114,7 +114,7 @@ Unlike Queries, you can call Actions directly (without wrapping them in a hook) All that's left now is adding this form to the page component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from "react"; @@ -171,7 +171,7 @@ Since we've already created one task together, try to create this one yourself. Declaring the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -182,7 +182,7 @@ action updateTask { } ``` - + Implementing the Action on the server: @@ -210,7 +210,7 @@ export const updateTask: UpdateTask = async ( You can now call `updateTask` from the React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent, ChangeEvent } from "react"; diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 38700cdea9..f9cd3c5f8b 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -24,7 +24,7 @@ Since Wasp manages authentication, it will create [the auth related entities](.. You must only add the `User` Entity to keep track of who owns which tasks: - + ```prisma title="schema.prisma" // ... @@ -38,7 +38,7 @@ model User { Next, tell Wasp to use full-stack [authentication](../auth/overview): - + ```wasp title="main.wasp" app TodoApp { @@ -65,7 +65,7 @@ app TodoApp { Don't forget to update the database schema by running: - + ```sh wasp db migrate-dev @@ -86,7 +86,7 @@ Wasp also supports authentication using [Google](../auth/social-auth/google), [G Wasp creates the login and signup forms for us, but we still need to define the pages to display those forms on. We'll start by declaring the pages in the Wasp file: - + ```wasp title="main.wasp" // ... @@ -106,7 +106,7 @@ Great, Wasp now knows these pages exist! Here's the React code for the pages you've just imported: - + ```tsx title="src/LoginPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -127,7 +127,7 @@ export const LoginPage = () => { The signup page is very similar to the login page: - + ```tsx title="src/SignupPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -156,7 +156,7 @@ export const SignupPage = () => { We don't want users who are not logged in to access the main page, because they won't be able to create any tasks. So let's make the page private by requiring the user to be logged in: - + ```wasp title="main.wasp" // ... @@ -172,7 +172,7 @@ Now that auth is required for this page, unauthenticated users will be redirecte Additionally, when `authRequired` is `true`, the page's React component will be provided a `user` object as prop. - + ```tsx title="src/MainPage.tsx" auto-js import type { AuthUser } from "wasp/auth"; @@ -208,7 +208,7 @@ However, you will notice that if you try logging in as different users and creat First, let's define a one-to-many relation between users and tasks (check the [Prisma docs on relations](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/relations)): - + ```prisma title="schema.prisma" // ... @@ -232,7 +232,7 @@ model Task { As always, you must migrate the database after changing the Entities: - + ```sh wasp db migrate-dev ``` @@ -249,7 +249,7 @@ Instead, we would do a data migration to take care of those tasks, even if it me Next, let's update the queries and actions to forbid access to non-authenticated users and to operate only on the currently logged-in user's tasks: - + ```ts title="src/queries.ts" auto-js import type { Task } from "wasp/entities"; @@ -271,7 +271,7 @@ export const getTasks: GetTasks = async (args, context) => { }; ``` - + ```ts title="src/actions.ts" auto-js import type { Task } from "wasp/entities"; @@ -337,7 +337,7 @@ You will see that each user has their tasks, just as we specified in our code! Last, but not least, let's add the logout functionality: - + ```tsx title="src/MainPage.tsx" auto-js with-hole // ... diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index 5c6bcda37e..f8950322f0 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -7,39 +7,39 @@ This component serves two purposes: */ export function TutorialAction({ action, - step, + id, }: { action: Action; - step: string; + id: string; }) { return ( process.env.NODE_ENV !== "production" && (
- +
) ); } function TutorialActionStep({ - step, + id, action, }: { - step: string; + id: string; action: Action; }) { return (
tutorial action: {action}
- {step} + {id} { - navigator.clipboard.writeText(step); + navigator.clipboard.writeText(id); }} > [copy] diff --git a/web/tutorial-app-generator/.gitignore b/web/tutorial-app-generator/.gitignore index d5700888a3..72335df6a5 100644 --- a/web/tutorial-app-generator/.gitignore +++ b/web/tutorial-app-generator/.gitignore @@ -1,2 +1,2 @@ node_modules/ - +TodoApp/ diff --git a/web/tutorial-app-generator/README.md b/web/tutorial-app-generator/README.md index cfe2a433f6..43235e8c86 100644 --- a/web/tutorial-app-generator/README.md +++ b/web/tutorial-app-generator/README.md @@ -15,7 +15,7 @@ npm install Parses tutorial markdown files and generates a complete app with all steps applied: ```bash -npm start generate-app +npm run generate-app ``` This will: @@ -30,7 +30,7 @@ This will: Edit an existing tutorial step and regenerate patches: ```bash -npm start edit-step --step-name +npm run edit-step --step-id ``` This will: @@ -54,11 +54,11 @@ Make sure you first run the `generate-app` command to create the initial app str Use `` components in markdown: ```jsx - - + + ``` Supported actions: - `apply-patch` - Applies a git patch file -- `migrate-db +- `migrate-db` diff --git a/web/tutorial-app-generator/package-lock.json b/web/tutorial-app-generator/package-lock.json index 172ba025ed..94a2482ae1 100644 --- a/web/tutorial-app-generator/package-lock.json +++ b/web/tutorial-app-generator/package-lock.json @@ -10,10 +10,10 @@ "license": "MIT", "dependencies": { "@commander-js/extra-typings": "^14.0.0", + "@inquirer/prompts": "^7.8.0", "acorn": "8.15.0", "commander": "^14.0.0", "dedent": "^1.6.0", - "enquirer": "^2.4.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-frontmatter": "^2.0.1", "mdast-util-mdx": "^3.0.0", @@ -22,12 +22,13 @@ "micromark-extension-mdx-jsx": "^3.0.2", "parse-git-diff": "0.0.19", "remark-comment": "^1.0.0", + "tsx": "^4.20.3", "unist-util-visit": "^5.0.0", "zx": "^8.5.3" }, "devDependencies": { - "@types/mdast": "^4.0.4", - "tsx": "^4.19.4" + "@types/fs-extra": "^11.0.4", + "@types/mdast": "^4.0.4" } }, "node_modules/@commander-js/extra-typings": { @@ -40,13 +41,12 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", - "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -57,13 +57,12 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz", - "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -74,13 +73,12 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz", - "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -91,13 +89,12 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz", - "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -108,13 +105,12 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", - "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -125,13 +121,12 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz", - "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -142,13 +137,12 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz", - "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -159,13 +153,12 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz", - "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -176,13 +169,12 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz", - "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -193,13 +185,12 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz", - "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -210,13 +201,12 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz", - "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -227,13 +217,12 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz", - "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -244,13 +233,12 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz", - "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -261,13 +249,12 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz", - "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -278,13 +265,12 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz", - "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -295,13 +281,12 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz", - "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -312,13 +297,12 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz", - "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -329,13 +313,12 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz", - "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -346,13 +329,12 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz", - "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -363,13 +345,12 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz", - "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -380,13 +361,12 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz", - "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -397,13 +377,12 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz", - "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -414,13 +393,12 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz", - "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -431,13 +409,12 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz", - "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -448,13 +425,12 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz", - "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -465,13 +441,12 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz", - "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -481,6 +456,310 @@ "node": ">=18" } }, + "node_modules/@inquirer/checkbox": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.2.0.tgz", + "integrity": "sha512-fdSw07FLJEU5vbpOPzXo5c6xmMGDzbZE2+niuDHX5N6mc6V0Ebso/q3xiHra4D73+PMsC8MJmcaZKuAAoaQsSA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.14", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz", + "integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.15", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.15.tgz", + "integrity": "sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==", + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.15.tgz", + "integrity": "sha512-wst31XT8DnGOSS4nNJDIklGKnf+8shuauVrWzgKegWUe28zfCftcWZ2vktGdzJgcylWSS2SrDnYUb6alZcwnCQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.17.tgz", + "integrity": "sha512-PSqy9VmJx/VbE3CT453yOfNa+PykpKg/0SYP7odez1/NWBGuDXgPhp4AeGYYKjhLn5lUUavVS/JbeYMPdH50Mw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.1.tgz", + "integrity": "sha512-tVC+O1rBl0lJpoUZv4xY+WGWY8V5b0zxU1XDsMsIHYregdh7bN5X5QnIONNBAl0K765FYlAfNHS2Bhn7SSOVow==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.17.tgz", + "integrity": "sha512-GcvGHkyIgfZgVnnimURdOueMk0CztycfC8NZTiIY9arIAkeOgt6zG57G+7vC59Jns3UX27LMkPKnKWAOF5xEYg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.17.tgz", + "integrity": "sha512-DJolTnNeZ00E1+1TW+8614F7rOJJCM4y4BAGQ3Gq6kQIG+OJ4zr3GLjIjVVJCbKsk2jmkmv6v2kQuN/vriHdZA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.0.tgz", + "integrity": "sha512-JHwGbQ6wjf1dxxnalDYpZwZxUEosT+6CPGD9Zh4sm9WXdtUp9XODCQD3NjSTmu+0OAyxWXNOqf0spjIymJa2Tw==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.2.0", + "@inquirer/confirm": "^5.1.14", + "@inquirer/editor": "^4.2.15", + "@inquirer/expand": "^4.0.17", + "@inquirer/input": "^4.2.1", + "@inquirer/number": "^3.0.17", + "@inquirer/password": "^4.0.17", + "@inquirer/rawlist": "^4.1.5", + "@inquirer/search": "^3.1.0", + "@inquirer/select": "^4.3.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.5.tgz", + "integrity": "sha512-R5qMyGJqtDdi4Ht521iAkNqyB6p2UPuZUbMifakg1sWtu24gc2Z8CJuw8rP081OckNDMgtDCuLe42Q2Kr3BolA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.1.0.tgz", + "integrity": "sha512-PMk1+O/WBcYJDq2H7foV0aAZSmDdkzZB9Mw2v/DmONRJopwA/128cS9M/TXWLKKdEQKZnKwBzqu2G4x/2Nqx8Q==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.3.1.tgz", + "integrity": "sha512-Gfl/5sqOF5vS/LIrSndFgOh7jgoe0UXEizDqahFRkq5aJBLegZ6WjuMh/hVEJwlFQjyLq1z9fRtvUMkb7jM1LA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.15", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -505,6 +784,17 @@ "@types/estree": "*" } }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -514,6 +804,16 @@ "@types/unist": "*" } }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -529,6 +829,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz", + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -547,13 +857,19 @@ "node": ">=0.4.0" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, "engines": { - "node": ">=6" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ansi-regex": { @@ -565,6 +881,21 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -615,6 +946,39 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", @@ -690,24 +1054,16 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", - "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", - "dev": true, + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -717,32 +1073,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.6", - "@esbuild/android-arm": "0.25.6", - "@esbuild/android-arm64": "0.25.6", - "@esbuild/android-x64": "0.25.6", - "@esbuild/darwin-arm64": "0.25.6", - "@esbuild/darwin-x64": "0.25.6", - "@esbuild/freebsd-arm64": "0.25.6", - "@esbuild/freebsd-x64": "0.25.6", - "@esbuild/linux-arm": "0.25.6", - "@esbuild/linux-arm64": "0.25.6", - "@esbuild/linux-ia32": "0.25.6", - "@esbuild/linux-loong64": "0.25.6", - "@esbuild/linux-mips64el": "0.25.6", - "@esbuild/linux-ppc64": "0.25.6", - "@esbuild/linux-riscv64": "0.25.6", - "@esbuild/linux-s390x": "0.25.6", - "@esbuild/linux-x64": "0.25.6", - "@esbuild/netbsd-arm64": "0.25.6", - "@esbuild/netbsd-x64": "0.25.6", - "@esbuild/openbsd-arm64": "0.25.6", - "@esbuild/openbsd-x64": "0.25.6", - "@esbuild/openharmony-arm64": "0.25.6", - "@esbuild/sunos-x64": "0.25.6", - "@esbuild/win32-arm64": "0.25.6", - "@esbuild/win32-ia32": "0.25.6", - "@esbuild/win32-x64": "0.25.6" + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" } }, "node_modules/escape-string-regexp": { @@ -781,6 +1137,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "license": "MIT", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/fault": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", @@ -806,7 +1176,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -821,7 +1190,6 @@ "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", - "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -830,6 +1198,18 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -864,6 +1244,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -1589,6 +1978,24 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -1707,12 +2114,43 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -1739,11 +2177,22 @@ "node": ">=8" } }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/tsx": { "version": "4.20.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.25.0", @@ -1759,6 +2208,25 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "devOptional": true, + "license": "MIT" + }, "node_modules/unist-util-is": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", @@ -1841,6 +2309,32 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/web/tutorial-app-generator/package.json b/web/tutorial-app-generator/package.json index 8f285665d7..13c67e3f62 100644 --- a/web/tutorial-app-generator/package.json +++ b/web/tutorial-app-generator/package.json @@ -4,15 +4,16 @@ "type": "module", "license": "MIT", "scripts": { - "parse-markdown": "tsx ./src/markdown/index.ts", - "start": "tsx ./src/index.ts" + "generate-app": "tsx ./src/index.ts generate-app", + "edit-step": "tsx ./src/index.ts edit-step", + "list-steps": "tsx ./src/index.ts list-steps" }, "dependencies": { "@commander-js/extra-typings": "^14.0.0", + "@inquirer/prompts": "^7.8.0", "acorn": "8.15.0", "commander": "^14.0.0", "dedent": "^1.6.0", - "enquirer": "^2.4.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-frontmatter": "^2.0.1", "mdast-util-mdx": "^3.0.0", @@ -21,11 +22,12 @@ "micromark-extension-mdx-jsx": "^3.0.2", "parse-git-diff": "0.0.19", "remark-comment": "^1.0.0", + "tsx": "^4.20.3", "unist-util-visit": "^5.0.0", "zx": "^8.5.3" }, "devDependencies": { - "@types/mdast": "^4.0.4", - "tsx": "^4.19.4" + "@types/fs-extra": "^11.0.4", + "@types/mdast": "^4.0.4" } } diff --git a/web/tutorial-app-generator/src/brandedTypes.ts b/web/tutorial-app-generator/src/brandedTypes.ts index 638a6d4b5a..d3fa501352 100644 --- a/web/tutorial-app-generator/src/brandedTypes.ts +++ b/web/tutorial-app-generator/src/brandedTypes.ts @@ -6,5 +6,5 @@ export type AppParentDirPath = Branded; export type PatchesDirPath = Branded; export type TutorialDirPath = Branded; export type PatchFilePath = Branded; -export type StepName = Branded; +export type StepId = Branded; export type MarkdownFilePath = Branded; diff --git a/web/tutorial-app-generator/src/commands/edit-step/index.ts b/web/tutorial-app-generator/src/commands/edit-step/index.ts index cf64dd10a7..f9573418bb 100644 --- a/web/tutorial-app-generator/src/commands/edit-step/index.ts +++ b/web/tutorial-app-generator/src/commands/edit-step/index.ts @@ -1,37 +1,48 @@ import fs from "fs/promises"; import { Command, Option } from "@commander-js/extra-typings"; -import { $ } from "zx"; +import { $, ProcessOutput } from "zx"; -import Enquirer from "enquirer"; +import { confirm, select } from "@inquirer/prompts"; import type { AppDirPath } from "../../brandedTypes"; import type { Action, ApplyPatchAction } from "../../executeSteps/actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; import { generatePatchFromRevision } from "../../git"; import { log } from "../../log"; import { appDir, mainBranchName, tutorialDir } from "../../project"; +import { generateApp } from "../generate-app"; export const editStepCommand = new Command("edit-step") .description("Edit a step in the tutorial app") - .addOption(new Option("--step-name ", "Name of the step to edit")) - .action(async ({ stepName }) => { - const actions: Action[] = await getActionsFromTutorialFiles(tutorialDir); - - const action = actions.find((a) => a.stepName === stepName); - - if (!action) { - throw new Error(`Step with name "${stepName}" not found.`); + .addOption(new Option("--step-id ", "Name of the step to edit")) + .addOption( + new Option( + "--skip-generating-app", + "Skip generating app before editing step", + ), + ) + .action(async ({ stepId, skipGeneratingApp }) => { + const actions = await getActionsFromTutorialFiles(tutorialDir); + + const action = await ensureAction({ + actions, + stepIdOptionValue: stepId, + }); + + if (!skipGeneratingApp) { + log("info", "Generating app before editing step..."); + await generateApp(actions); + } else { + log("info", `Skipping app generation, using existing app in ${appDir}`); } - if (action.kind !== "apply-patch") { - throw new Error(`Step "${stepName}" is not an editable step.`); - } + log("info", `Editing step ${action.displayName}...`); await editStepPatch({ appDir, action }); await extractCommitsIntoPatches(actions); - log("success", `Edit completed for step ${action.stepName}!`); + log("success", `Edit completed for step ${action.displayName}!`); }); async function editStepPatch({ @@ -41,33 +52,35 @@ async function editStepPatch({ appDir: AppDirPath; action: ApplyPatchAction; }): Promise { - await $({ cwd: appDir })`git switch ${mainBranchName}`.quiet(true); + await $({ cwd: appDir })`git switch ${mainBranchName}`; const fixesBranchName = "fixes"; await $({ cwd: appDir, - quiet: true, - })`git switch --force-create ${fixesBranchName} ${action.stepName}`; - - await Enquirer.prompt({ - type: "confirm", - name: "edit", - message: `Apply edit for step "${action.stepName}" and press Enter`, - initial: true, + })`git switch --force-create ${fixesBranchName} ${action.id}`; + + await confirm({ + message: `Apply edit for step "${action.displayName}" and press Enter`, }); await $({ cwd: appDir })`git add .`; await $({ cwd: appDir })`git commit --amend --no-edit`; - await $({ cwd: appDir })`git tag -f ${action.stepName}`; + await $({ cwd: appDir })`git tag -f ${action.id}`; await $({ cwd: appDir })`git switch ${mainBranchName}`; - await $({ cwd: appDir, throw: false })`git rebase ${fixesBranchName}`; - - await Enquirer.prompt({ - type: "confirm", - name: "issues", - message: `If there are any rebase issues, resolve them and press Enter to continue`, - initial: true, - }); + try { + await $({ cwd: appDir, throw: false })`git rebase ${fixesBranchName}`; + } catch (error: unknown) { + if ( + error instanceof ProcessOutput && + error.stderr.includes("git rebase --continue") + ) { + await confirm({ + message: `Resolve rebase issues and press Enter to continue`, + }); + } else { + throw error; + } + } } async function extractCommitsIntoPatches(actions: Action[]): Promise { @@ -76,8 +89,45 @@ async function extractCommitsIntoPatches(actions: Action[]): Promise { ); for (const action of applyPatchActions) { - log("info", `Updating patch for step ${action.stepName}`); - const patch = await generatePatchFromRevision(appDir, action.stepName); + log("info", `Updating patch for step ${action.displayName}`); + const patch = await generatePatchFromRevision(appDir, action.id); await fs.writeFile(action.patchFilePath, patch, "utf-8"); } } + +async function ensureAction({ + actions, + stepIdOptionValue, +}: { + actions: Action[]; + stepIdOptionValue: string | undefined; +}): Promise { + const applyPatchActions = actions.filter( + (action) => action.kind === "apply-patch", + ); + + if (!stepIdOptionValue) { + return askUserToSelectAction(applyPatchActions); + } + + const action = applyPatchActions.find((a) => a.id === stepIdOptionValue); + if (!action) { + throw new Error( + `Apply patch step with ID "${stepIdOptionValue}" not found.`, + ); + } + return action; +} + +async function askUserToSelectAction( + actions: ApplyPatchAction[], +): Promise { + const selectedStepId = await select({ + message: "Select a step to edit", + choices: actions.map((action) => ({ + name: action.displayName, + value: action.id, + })), + }); + return actions.find((a) => a.id === selectedStepId) as ApplyPatchAction; +} diff --git a/web/tutorial-app-generator/src/commands/generate-app/index.ts b/web/tutorial-app-generator/src/commands/generate-app/index.ts index 16281b9caf..7b1b291596 100644 --- a/web/tutorial-app-generator/src/commands/generate-app/index.ts +++ b/web/tutorial-app-generator/src/commands/generate-app/index.ts @@ -20,13 +20,18 @@ import { waspNew } from "../../waspCli"; export const generateAppCommand = new Command("generate-app") .description("Generate a new Wasp app based on the tutorial steps") .action(async () => { - const actions: Action[] = await getActionsFromTutorialFiles(tutorialDir); + const actions = await getActionsFromTutorialFiles(tutorialDir); + log("success", `Found ${actions.length} actions in tutorial files.`); - await prepareApp({ appDir, appParentDir, appName, mainBranchName }); - await executeSteps({ appDir, patchesDir, actions }); - log("success", "Tutorial app has been successfully generated!"); + await generateApp(actions); }); +export async function generateApp(actions: Action[]): Promise { + await prepareApp({ appDir, appParentDir, appName, mainBranchName }); + await executeSteps({ appDir, patchesDir, actions }); + log("success", `Tutorial app has been successfully generated in ${appDir}`); +} + async function prepareApp({ appName, appParentDir, diff --git a/web/tutorial-app-generator/src/commands/list-steps/index.ts b/web/tutorial-app-generator/src/commands/list-steps/index.ts new file mode 100644 index 0000000000..49ee795319 --- /dev/null +++ b/web/tutorial-app-generator/src/commands/list-steps/index.ts @@ -0,0 +1,62 @@ +import { Command } from "@commander-js/extra-typings"; +import { basename } from "path"; +import { chalk } from "zx"; + +import type { Action } from "../../executeSteps/actions"; +import { getActionsFromTutorialFiles } from "../../extractSteps"; +import { tutorialDir } from "../../project"; + +type ActionsGroupedByFile = Map; + +export const listStepsCommand = new Command("list-steps") + .description("List all steps in the tutorial") + .action(async () => { + const actions = await getActionsFromTutorialFiles(tutorialDir); + const actionsGroupedByFile = groupActionsBySourceFile(actions); + displayGroupedActions(actionsGroupedByFile); + }); + +function groupActionsBySourceFile(actions: Action[]): ActionsGroupedByFile { + const groupedActions = new Map(); + + for (const action of actions) { + const filename = basename(action.sourceFilePath); + const existingActions = groupedActions.get(filename) ?? []; + groupedActions.set(filename, [...existingActions, action]); + } + + return groupedActions; +} + +function displayGroupedActions( + actionsGroupedByFile: ActionsGroupedByFile, +): void { + for (const [filename, fileActions] of actionsGroupedByFile) { + displayFileHeader(filename); + displayActionsForFile(fileActions); + + console.log(); + } +} + +function displayFileHeader(filename: string): void { + console.log(chalk.bold.magenta(filename)); + console.log(); +} + +function displayActionsForFile(actions: Action[]): void { + type ActionKind = Action["kind"]; + const kindConfig: Record string }> = { + "apply-patch": { color: chalk.green }, + "migrate-db": { color: chalk.blue }, + }; + + actions.forEach((action) => { + const config = kindConfig[action.kind]; + const coloredKind = config.color(action.kind); + + console.log( + `- ${chalk.bold(action.id)} ${chalk.gray("(")}${coloredKind}${chalk.gray(")")}`, + ); + }); +} diff --git a/web/tutorial-app-generator/src/executeSteps/actions.ts b/web/tutorial-app-generator/src/executeSteps/actions.ts index d9ebe59161..53c23a8b6d 100644 --- a/web/tutorial-app-generator/src/executeSteps/actions.ts +++ b/web/tutorial-app-generator/src/executeSteps/actions.ts @@ -1,19 +1,16 @@ -import path from "path"; -import type { - MarkdownFilePath, - PatchFilePath, - StepName, -} from "../brandedTypes"; +import path, { basename } from "path"; +import type { MarkdownFilePath, PatchFilePath, StepId } from "../brandedTypes"; import { getFileNameWithoutExtension } from "../files"; import { patchesDir } from "../project"; export type ActionCommon = { - stepName: StepName; + id: StepId; sourceFilePath: MarkdownFilePath; }; export type ApplyPatchAction = { kind: "apply-patch"; + displayName: string; patchFilePath: PatchFilePath; } & ActionCommon; @@ -39,12 +36,13 @@ export function createApplyPatchAction( return { ...commonData, kind: "apply-patch", + displayName: `${basename(commonData.sourceFilePath)} / ${commonData.id}`, patchFilePath, }; } function getPatchFilePath(action: ActionCommon): PatchFilePath { const sourceFileName = getFileNameWithoutExtension(action.sourceFilePath); - const patchFileName = `${sourceFileName}__${action.stepName}.patch`; + const patchFileName = `${sourceFileName}__${action.id}.patch`; return path.resolve(patchesDir, patchFileName) as PatchFilePath; } diff --git a/web/tutorial-app-generator/src/executeSteps/index.ts b/web/tutorial-app-generator/src/executeSteps/index.ts index 915c244e61..10ca73a259 100644 --- a/web/tutorial-app-generator/src/executeSteps/index.ts +++ b/web/tutorial-app-generator/src/executeSteps/index.ts @@ -1,7 +1,6 @@ -import { chalk } from "zx"; +import { chalk, fs } from "zx"; import type { AppDirPath, PatchesDirPath } from "../brandedTypes"; -import { ensureDirExists } from "../files"; import { tagAllChanges } from "../git"; import { log } from "../log"; import { waspDbMigrate } from "../waspCli"; @@ -18,9 +17,9 @@ export async function executeSteps({ actions: Action[]; }): Promise { for (const action of actions) { - log("info", `${chalk.bold(`[step ${action.stepName}]`)} ${action.kind}`); + log("info", `${chalk.bold(`[step ${action.id}]`)} ${action.kind}`); - await ensureDirExists(patchesDir); + await fs.ensureDir(patchesDir); try { switch (action.kind) { @@ -30,22 +29,22 @@ export async function executeSteps({ } catch (err) { log( "error", - `Failed to apply patch for step ${action.stepName}:\n${err}`, + `Failed to apply patch for step ${action.displayName}:\n${err}`, ); await tryToFixPatch({ appDir, action }); await applyPatch({ appDir, action }); } break; case "migrate-db": - await waspDbMigrate(appDir, action.stepName); + await waspDbMigrate(appDir, action.id); break; default: action satisfies never; } } catch (err) { - log("error", `Error in step ${action.stepName}:\n\n${err}`); + log("error", `Error in step with ID ${action.id}:\n\n${err}`); process.exit(1); } - await tagAllChanges(appDir, action.stepName); + await tagAllChanges(appDir, action.id); } } diff --git a/web/tutorial-app-generator/src/executeSteps/patch.ts b/web/tutorial-app-generator/src/executeSteps/patch.ts index 8fcaed3910..e2e0b02bc6 100644 --- a/web/tutorial-app-generator/src/executeSteps/patch.ts +++ b/web/tutorial-app-generator/src/executeSteps/patch.ts @@ -1,11 +1,8 @@ -import fs from "fs/promises"; - -import Enquirer from "enquirer"; +import { confirm } from "@inquirer/prompts"; import parseGitDiff from "parse-git-diff"; -import { $ } from "zx"; +import { $, fs } from "zx"; import type { AppDirPath } from "../brandedTypes"; -import { doesFileExist } from "../files"; import { generatePatchFromAllChanges } from "../git"; import { log } from "../log"; import type { ApplyPatchAction } from "./actions"; @@ -29,9 +26,9 @@ export async function tryToFixPatch({ appDir: AppDirPath; action: ApplyPatchAction; }): Promise { - log("info", `Trying to fix patch for step: ${action.stepName}`); + log("info", `Trying to fix patch for step: ${action.displayName}`); - if (await doesFileExist(action.patchFilePath)) { + if (await fs.pathExists(action.patchFilePath)) { log("info", `Removing existing patch file: ${action.patchFilePath}`); await fs.unlink(action.patchFilePath); } @@ -48,11 +45,8 @@ export async function createPatchForStep({ }) { log("info", "Opening tutorial app in VS Code"); await $`code ${appDir}`; - await Enquirer.prompt({ - type: "confirm", - name: "edit", - message: `Apply edit for ${action.stepName} and press Enter`, - initial: true, + await confirm({ + message: `Apply edit for ${action.displayName} and press Enter`, }); const patch = await generatePatchFromAllChanges(appDir); diff --git a/web/tutorial-app-generator/src/extractSteps/index.ts b/web/tutorial-app-generator/src/extractSteps/index.ts index bf2e932c68..739acaac30 100644 --- a/web/tutorial-app-generator/src/extractSteps/index.ts +++ b/web/tutorial-app-generator/src/extractSteps/index.ts @@ -9,7 +9,7 @@ import { visit } from "unist-util-visit"; import type { MarkdownFilePath, - StepName, + StepId, TutorialDirPath, } from "../brandedTypes.js"; import { @@ -18,7 +18,6 @@ import { type Action, type ActionCommon, } from "../executeSteps/actions.js"; -import { log } from "../log.js"; export async function getActionsFromTutorialFiles( tutorialDir: TutorialDirPath, @@ -31,7 +30,6 @@ export async function getActionsFromTutorialFiles( actions.push(...fileActions); } - log("success", `Found ${actions.length} actions in tutorial files.`); return actions; } @@ -69,16 +67,24 @@ async function getActionsFromMarkdownFile( if (node.name !== tutorialComponentName) { return; } - const stepName = getAttributeValue(node, "step") as StepName | null; + const stepId = getAttributeValue(node, "id") as StepId | null; const actionName = getAttributeValue(node, "action"); - if (!stepName || !actionName) { - throw new Error("Step and action attributes are required"); + if (!stepId) { + throw new Error( + `TutorialAction component requires the 'id' attribute. File: ${sourceFilePath}`, + ); + } + + if (!actionName) { + throw new Error( + `TutorialAction component requires the 'action' attribute. File: ${sourceFilePath}`, + ); } actions.push( createAction(actionName, { - stepName, + id: stepId, sourceFilePath, }), ); diff --git a/web/tutorial-app-generator/src/files.ts b/web/tutorial-app-generator/src/files.ts index 166eacb2e4..26f52fe037 100644 --- a/web/tutorial-app-generator/src/files.ts +++ b/web/tutorial-app-generator/src/files.ts @@ -1,19 +1,5 @@ -import fs from "fs/promises"; import path from "path"; -export async function ensureDirExists(dirPath: string): Promise { - await fs.mkdir(dirPath, { recursive: true }); -} - -export async function doesFileExist(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch (error) { - return false; - } -} - export function getFileNameWithoutExtension(filePath: string): string { return path.basename(filePath, path.extname(filePath)); } diff --git a/web/tutorial-app-generator/src/git.ts b/web/tutorial-app-generator/src/git.ts index 31efbb3d8f..5eb240162e 100644 --- a/web/tutorial-app-generator/src/git.ts +++ b/web/tutorial-app-generator/src/git.ts @@ -4,7 +4,7 @@ import { log } from "./log"; export async function tagAllChanges(gitRepoDir: string, tagName: string) { await commitAllChanges(gitRepoDir, `Changes for tag: ${tagName}`); - await $({ cwd: gitRepoDir })`git tag ${tagName}`; + await $({ cwd: gitRepoDir })`git tag ${tagName} -m ${tagName}`; log("info", `Tagged changes with "${tagName}"`); } @@ -22,7 +22,7 @@ async function commitAllChanges( message: string, ): Promise { await $({ cwd: gitRepoDir })`git add .`; - await $({ cwd: gitRepoDir })`git commit -m "${message}"`; + await $({ cwd: gitRepoDir })`git commit -m ${message}`; } async function removeLastCommit(gitRepoDir: string): Promise { diff --git a/web/tutorial-app-generator/src/index.ts b/web/tutorial-app-generator/src/index.ts index a58a7ecf0e..0aac71cf07 100644 --- a/web/tutorial-app-generator/src/index.ts +++ b/web/tutorial-app-generator/src/index.ts @@ -1,9 +1,11 @@ import { program } from "@commander-js/extra-typings"; import { editStepCommand } from "./commands/edit-step"; import { generateAppCommand } from "./commands/generate-app"; +import { listStepsCommand } from "./commands/list-steps"; program .addCommand(generateAppCommand) .addCommand(editStepCommand) + .addCommand(listStepsCommand) .parse(process.argv) .opts(); diff --git a/web/tutorial-app-generator/src/project.ts b/web/tutorial-app-generator/src/project.ts index 6f0225d01c..fcd31efa7b 100644 --- a/web/tutorial-app-generator/src/project.ts +++ b/web/tutorial-app-generator/src/project.ts @@ -1,5 +1,4 @@ import path from "path"; -import os from "os"; import type { AppDirPath, @@ -10,7 +9,7 @@ import type { } from "./brandedTypes"; export const appName = "TodoApp" as AppName; -export const appParentDir = path.resolve(os.tmpdir()) as AppParentDirPath; +export const appParentDir = path.resolve("./") as AppParentDirPath; export const appDir = path.resolve(appParentDir, appName) as AppDirPath; export const tutorialDir = path.resolve("../docs/tutorial") as TutorialDirPath; export const patchesDir = path.resolve( From 5ad4372f6dc060236a8ba09a2b572f11b0648ee8 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Tue, 5 Aug 2025 11:12:14 +0200 Subject: [PATCH 24/48] Cleanup --- .../src/commands/edit-step/index.ts | 1 + .../src/commands/generate-app/index.ts | 2 +- .../src/commands/list-steps/index.ts | 14 +++++--------- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/web/tutorial-app-generator/src/commands/edit-step/index.ts b/web/tutorial-app-generator/src/commands/edit-step/index.ts index f9573418bb..63a1a85f6a 100644 --- a/web/tutorial-app-generator/src/commands/edit-step/index.ts +++ b/web/tutorial-app-generator/src/commands/edit-step/index.ts @@ -23,6 +23,7 @@ export const editStepCommand = new Command("edit-step") ) .action(async ({ stepId, skipGeneratingApp }) => { const actions = await getActionsFromTutorialFiles(tutorialDir); + log("info", `Found ${actions.length} actions in tutorial files.`); const action = await ensureAction({ actions, diff --git a/web/tutorial-app-generator/src/commands/generate-app/index.ts b/web/tutorial-app-generator/src/commands/generate-app/index.ts index 7b1b291596..51f00dc8d4 100644 --- a/web/tutorial-app-generator/src/commands/generate-app/index.ts +++ b/web/tutorial-app-generator/src/commands/generate-app/index.ts @@ -21,7 +21,7 @@ export const generateAppCommand = new Command("generate-app") .description("Generate a new Wasp app based on the tutorial steps") .action(async () => { const actions = await getActionsFromTutorialFiles(tutorialDir); - log("success", `Found ${actions.length} actions in tutorial files.`); + log("info", `Found ${actions.length} actions in tutorial files.`); await generateApp(actions); }); diff --git a/web/tutorial-app-generator/src/commands/list-steps/index.ts b/web/tutorial-app-generator/src/commands/list-steps/index.ts index 49ee795319..3adc75da9f 100644 --- a/web/tutorial-app-generator/src/commands/list-steps/index.ts +++ b/web/tutorial-app-generator/src/commands/list-steps/index.ts @@ -45,18 +45,14 @@ function displayFileHeader(filename: string): void { } function displayActionsForFile(actions: Action[]): void { - type ActionKind = Action["kind"]; - const kindConfig: Record string }> = { - "apply-patch": { color: chalk.green }, - "migrate-db": { color: chalk.blue }, + const kindColorMap: Record string> = { + "apply-patch": chalk.green, + "migrate-db": chalk.blue, }; actions.forEach((action) => { - const config = kindConfig[action.kind]; - const coloredKind = config.color(action.kind); + const kindColorFn = kindColorMap[action.kind]; - console.log( - `- ${chalk.bold(action.id)} ${chalk.gray("(")}${coloredKind}${chalk.gray(")")}`, - ); + console.log(`- ${chalk.bold(action.id)} (${kindColorFn(action.kind)})`); }); } From 6522952a516362b5b23666da95c5ba96f8e9c4e3 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 6 Aug 2025 12:39:58 +0200 Subject: [PATCH 25/48] Formatting Signed-off-by: Mihovil Ilakovac --- web/docs/tutorial/TutorialAction.tsx | 16 ++-------------- web/tutorial-app-generator/package.json | 2 +- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index f8950322f0..8597ad3c0d 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -5,13 +5,7 @@ This component serves two purposes: 1. It provides metadata for the `tutorial-app-generator` on how to execute tutorial steps programmatically. 2. It renders tutorial step names during development for easier debugging. */ -export function TutorialAction({ - action, - id, -}: { - action: Action; - id: string; -}) { +export function TutorialAction({ action, id }: { action: Action; id: string }) { return ( process.env.NODE_ENV !== "production" && (
@@ -21,13 +15,7 @@ export function TutorialAction({ ); } -function TutorialActionStep({ - id, - action, -}: { - id: string; - action: Action; -}) { +function TutorialActionStep({ id, action }: { id: string; action: Action }) { return (
tutorial action: {action}
diff --git a/web/tutorial-app-generator/package.json b/web/tutorial-app-generator/package.json index 13c67e3f62..baa39f1970 100644 --- a/web/tutorial-app-generator/package.json +++ b/web/tutorial-app-generator/package.json @@ -4,8 +4,8 @@ "type": "module", "license": "MIT", "scripts": { - "generate-app": "tsx ./src/index.ts generate-app", "edit-step": "tsx ./src/index.ts edit-step", + "generate-app": "tsx ./src/index.ts generate-app", "list-steps": "tsx ./src/index.ts list-steps" }, "dependencies": { From 80095ad12639f9fd2fec07c5ca4a2d6a1456ca3f Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 6 Aug 2025 16:40:03 +0200 Subject: [PATCH 26/48] PR comments --- web/docs/tutorial/TutorialAction.tsx | 1 + web/tutorial-app-generator/README.md | 122 +++++++++++++----- .../src/brandedTypes.ts | 6 +- .../src/commands/edit-step/index.ts | 2 +- .../src/commands/list-steps/index.ts | 2 +- .../src/executeSteps/actions.ts | 7 +- .../src/executeSteps/patch.ts | 12 +- .../src/extractSteps/index.ts | 10 +- web/tutorial-app-generator/src/typeUtils.ts | 4 - 9 files changed, 116 insertions(+), 50 deletions(-) delete mode 100644 web/tutorial-app-generator/src/typeUtils.ts diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index 8597ad3c0d..8e48a8b450 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -1,3 +1,4 @@ +// If you change it here, make sure to also update the types in `tutorial-app-generator/src/executeSteps/actions.ts`. type Action = "apply-patch" | "migrate-db"; /* diff --git a/web/tutorial-app-generator/README.md b/web/tutorial-app-generator/README.md index 43235e8c86..a675052254 100644 --- a/web/tutorial-app-generator/README.md +++ b/web/tutorial-app-generator/README.md @@ -1,6 +1,10 @@ -# Tutorial App Generator +# Tutorial Steps Executor (tutse) -CLI tool that generates tutorial apps by parsing markdown files and applying patches. +A CLI tool that generates Wasp tutorial applications by parsing markdown files containing tutorial steps and automatically applying patches and database migrations. + +## Overview + +This tool reads tutorial markdown files, extracts `` components, and executes the corresponding actions to build a complete tutorial app step by step. Each step is tracked with git commits and tags for easy navigation and editing. ## Setup @@ -8,57 +12,115 @@ CLI tool that generates tutorial apps by parsing markdown files and applying pat npm install ``` -## Usage +## Commands ### Generate Tutorial App -Parses tutorial markdown files and generates a complete app with all steps applied: +Generates a complete tutorial app by executing all tutorial steps in sequence: ```bash npm run generate-app ``` -This will: - -1. Create a new Wasp app in `./TodoApp` -1. Initialize git repo with tagged commits -1. Parse tutorial files from `../docs/tutorial` -1. Apply patches for each tutorial step +This command: +1. Creates a new Wasp app using `wasp new` +2. Initializes a git repository with proper branching +3. Parses all tutorial markdown files from the tutorial directory +4. Executes actions (patches, migrations) in order +5. Creates git tags for each completed step +6. Outputs the final app in the configured app directory -### Edit Tutorial Step +### List Tutorial Steps -Edit an existing tutorial step and regenerate patches: +Display all available tutorial steps organized by source file: ```bash -npm run edit-step --step-id +npm run list-steps ``` -This will: +### Edit Tutorial Step -1. Switch to the step's git tag -1. Open interactive edit session -1. Update the patch file -1. Rebase subsequent commits +Edit a specific tutorial step and regenerate associated patches: -Make sure you first run the `generate-app` command to create the initial app structure. +```bash +npm run edit-step --step-id +# or +npm run edit-step # Interactive step selection +``` -## File Structure +Options: +- `--step-id `: Specify the step ID to edit +- `--skip-generating-app`: Skip app generation and use existing app -- `src/extractSteps/` - Parses markdown files for `` components -- `src/executeSteps/` - Applies patches and runs migrations -- `src/commands/` - CLI command implementations -- `../docs/tutorial` - Generated patch files for each tutorial step +This command: +1. Generates the app (if not skipped) +2. Switches to the step's git tag +3. Creates a `fixes` branch for editing +4. Opens a new shell in the app directory, allowing you to make code changes. +5. Once you exit the shell, it updates the patch file with your changes. +6. Rebases subsequent commits to incorporate changes ## Tutorial Markdown Format -Use `` components in markdown: +Tutorial steps are defined using `` JSX components within markdown files: ```jsx - - + + +``` + +### Required Attributes + +- `id`: Unique identifier for the step (used for git tags and patch file names) +- `action`: Type of action to execute + +### Supported Actions + +#### `apply-patch` +Applies a git patch file to modify the codebase: +- Looks for patch file at `patches/{step-id}.patch` +- Automatically attempts to fix failed patches +- Creates git commit with the changes + +#### `migrate-db` +Runs database migration using Wasp CLI, executes `wasp db migrate-dev`. + +## Tutorial File Organization + +Tutorial markdown files should be: +- Located in the configured tutorial directory +- Named with numeric prefixes for ordering (e.g., `01-setup.md`, `02-auth.md`) +- Contain `` components for executable steps + +Example tutorial file structure: +``` +tutorial/ +├── 01-getting-started.md +├── 02-database-setup.md +├── 03-authentication.md +└── 04-frontend-features.md ``` -Supported actions: +## Configuration + +The tool uses configuration from `src/project.ts`: +- Tutorial directory path +- App output directory +- Patches directory +- Git branch naming + +## Git Integration + +Each tutorial step creates: +- A git commit with the step changes +- A git tag with the step ID for easy navigation +- Proper branch management for editing workflows + +### Rebasing on Edit + +When you edit a tutorial step using `npm run edit-step`, the tool performs a git rebase to update all subsequent steps. Here's how it works: -- `apply-patch` - Applies a git patch file -- `migrate-db` +1. The tool checks out the commit just before the one you want to edit. +2. You make your changes in a temporary `fixes` branch. +3. A new patch is generated from your changes, and a new commit is created for the edited step. +4. The tool then automatically rebases all subsequent tutorial steps on top of this new commit. This ensures that your changes are propagated through the rest of the tutorial, and all patches and tags are updated accordingly. diff --git a/web/tutorial-app-generator/src/brandedTypes.ts b/web/tutorial-app-generator/src/brandedTypes.ts index d3fa501352..9974187de3 100644 --- a/web/tutorial-app-generator/src/brandedTypes.ts +++ b/web/tutorial-app-generator/src/brandedTypes.ts @@ -1,5 +1,3 @@ -import type { Branded } from "./typeUtils"; - export type AppName = Branded; export type AppDirPath = Branded; export type AppParentDirPath = Branded; @@ -8,3 +6,7 @@ export type TutorialDirPath = Branded; export type PatchFilePath = Branded; export type StepId = Branded; export type MarkdownFilePath = Branded; + +declare const __brand: unique symbol; +type Brand = { [__brand]: B }; +type Branded = T & Brand; diff --git a/web/tutorial-app-generator/src/commands/edit-step/index.ts b/web/tutorial-app-generator/src/commands/edit-step/index.ts index 63a1a85f6a..b22633ac27 100644 --- a/web/tutorial-app-generator/src/commands/edit-step/index.ts +++ b/web/tutorial-app-generator/src/commands/edit-step/index.ts @@ -61,7 +61,7 @@ async function editStepPatch({ })`git switch --force-create ${fixesBranchName} ${action.id}`; await confirm({ - message: `Apply edit for step "${action.displayName}" and press Enter`, + message: `Do the step ${action.displayName} (go into the docs and read what needs to be done) and press Enter`, }); await $({ cwd: appDir })`git add .`; diff --git a/web/tutorial-app-generator/src/commands/list-steps/index.ts b/web/tutorial-app-generator/src/commands/list-steps/index.ts index 3adc75da9f..111b7ca4a9 100644 --- a/web/tutorial-app-generator/src/commands/list-steps/index.ts +++ b/web/tutorial-app-generator/src/commands/list-steps/index.ts @@ -20,7 +20,7 @@ function groupActionsBySourceFile(actions: Action[]): ActionsGroupedByFile { const groupedActions = new Map(); for (const action of actions) { - const filename = basename(action.sourceFilePath); + const filename = basename(action.tutorialFilePath); const existingActions = groupedActions.get(filename) ?? []; groupedActions.set(filename, [...existingActions, action]); } diff --git a/web/tutorial-app-generator/src/executeSteps/actions.ts b/web/tutorial-app-generator/src/executeSteps/actions.ts index 53c23a8b6d..5d381781ee 100644 --- a/web/tutorial-app-generator/src/executeSteps/actions.ts +++ b/web/tutorial-app-generator/src/executeSteps/actions.ts @@ -5,9 +5,10 @@ import { patchesDir } from "../project"; export type ActionCommon = { id: StepId; - sourceFilePath: MarkdownFilePath; + tutorialFilePath: MarkdownFilePath; }; +// If modify or add new action kinds, make sure to also update the type in `docs/tutorial/TutorialAction.tsx`. export type ApplyPatchAction = { kind: "apply-patch"; displayName: string; @@ -36,13 +37,13 @@ export function createApplyPatchAction( return { ...commonData, kind: "apply-patch", - displayName: `${basename(commonData.sourceFilePath)} / ${commonData.id}`, + displayName: `${basename(commonData.tutorialFilePath)} / ${commonData.id}`, patchFilePath, }; } function getPatchFilePath(action: ActionCommon): PatchFilePath { - const sourceFileName = getFileNameWithoutExtension(action.sourceFilePath); + const sourceFileName = getFileNameWithoutExtension(action.tutorialFilePath); const patchFileName = `${sourceFileName}__${action.id}.patch`; return path.resolve(patchesDir, patchFileName) as PatchFilePath; } diff --git a/web/tutorial-app-generator/src/executeSteps/patch.ts b/web/tutorial-app-generator/src/executeSteps/patch.ts index e2e0b02bc6..49bb2d3780 100644 --- a/web/tutorial-app-generator/src/executeSteps/patch.ts +++ b/web/tutorial-app-generator/src/executeSteps/patch.ts @@ -43,17 +43,21 @@ export async function createPatchForStep({ appDir: AppDirPath; action: ApplyPatchAction; }) { - log("info", "Opening tutorial app in VS Code"); - await $`code ${appDir}`; + const wantsToOpenVSCode = await confirm({ + message: `Do you want to open the app in VS Code to make changes for step "${action.displayName}"?`, + }); + if (wantsToOpenVSCode) { + await $`code ${appDir}`; + } await confirm({ - message: `Apply edit for ${action.displayName} and press Enter`, + message: `Do the step ${action.displayName} and press Enter`, }); const patch = await generatePatchFromAllChanges(appDir); assertValidPatch(patch); await fs.writeFile(action.patchFilePath, patch, "utf-8"); - log("info", `Patch file created: ${action.patchFilePath}`); + log("success", `Patch file created: ${action.patchFilePath}`); } export async function assertValidPatch(patch: string): Promise { diff --git a/web/tutorial-app-generator/src/extractSteps/index.ts b/web/tutorial-app-generator/src/extractSteps/index.ts index 739acaac30..937ecabc92 100644 --- a/web/tutorial-app-generator/src/extractSteps/index.ts +++ b/web/tutorial-app-generator/src/extractSteps/index.ts @@ -52,10 +52,10 @@ async function getTutorialFilePaths( } async function getActionsFromMarkdownFile( - sourceFilePath: MarkdownFilePath, + tutorialFilePath: MarkdownFilePath, ): Promise { const actions: Action[] = []; - const fileContent = await fs.readFile(path.resolve(sourceFilePath)); + const fileContent = await fs.readFile(path.resolve(tutorialFilePath)); const ast = fromMarkdown(fileContent, { extensions: [mdxJsx({ acorn, addResult: true })], @@ -72,20 +72,20 @@ async function getActionsFromMarkdownFile( if (!stepId) { throw new Error( - `TutorialAction component requires the 'id' attribute. File: ${sourceFilePath}`, + `TutorialAction component requires the 'id' attribute. File: ${tutorialFilePath}`, ); } if (!actionName) { throw new Error( - `TutorialAction component requires the 'action' attribute. File: ${sourceFilePath}`, + `TutorialAction component requires the 'action' attribute. File: ${tutorialFilePath}`, ); } actions.push( createAction(actionName, { id: stepId, - sourceFilePath, + tutorialFilePath, }), ); }); diff --git a/web/tutorial-app-generator/src/typeUtils.ts b/web/tutorial-app-generator/src/typeUtils.ts deleted file mode 100644 index 5efc5a99b2..0000000000 --- a/web/tutorial-app-generator/src/typeUtils.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare const __brand: unique symbol; -type Brand = { [__brand]: B }; - -export type Branded = T & Brand; From 4e964f3f24e5c4c5ace04dcb1c6db38b01d94175 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 8 Aug 2025 16:28:10 +0200 Subject: [PATCH 27/48] Refactor code --- web/tutorial-app-generator/src/actions/git.ts | 118 ++++++++++++++++++ .../actions.ts => actions/index.ts} | 0 .../src/commands/edit-step/index.ts | 45 ++++--- .../generate-app/execute-steps.ts} | 23 ++-- .../src/commands/generate-app/index.ts | 12 +- .../src/commands/list-steps/index.ts | 2 +- web/tutorial-app-generator/src/editor.ts | 13 ++ .../src/executeSteps/patch.ts | 69 ---------- .../src/extractSteps/index.ts | 12 +- web/tutorial-app-generator/src/git.ts | 85 +++++++++++-- 10 files changed, 261 insertions(+), 118 deletions(-) create mode 100644 web/tutorial-app-generator/src/actions/git.ts rename web/tutorial-app-generator/src/{executeSteps/actions.ts => actions/index.ts} (100%) rename web/tutorial-app-generator/src/{executeSteps/index.ts => commands/generate-app/execute-steps.ts} (62%) create mode 100644 web/tutorial-app-generator/src/editor.ts delete mode 100644 web/tutorial-app-generator/src/executeSteps/patch.ts diff --git a/web/tutorial-app-generator/src/actions/git.ts b/web/tutorial-app-generator/src/actions/git.ts new file mode 100644 index 0000000000..cc7f984a44 --- /dev/null +++ b/web/tutorial-app-generator/src/actions/git.ts @@ -0,0 +1,118 @@ +import { confirm } from "@inquirer/prompts"; +import parseGitDiff from "parse-git-diff"; +import { fs } from "zx"; + +import type { Action, ApplyPatchAction } from "."; +import type { AppDirPath } from "../brandedTypes"; +import { askToOpenProjectInEditor } from "../editor"; +import { + applyPatch, + commitAllChanges, + createBranchFromRevision, + findCommitSHAForExactMessage, + generatePatchFromAllChanges, + generatePatchFromRevision, +} from "../git"; +import { log } from "../log"; + +export function commitActionChanges({ + appDir, + action, +}: { + appDir: AppDirPath; + action: Action; +}) { + return commitAllChanges(appDir, action.id); +} + +export function getActionCommitSHA({ + appDir, + action, +}: { + appDir: AppDirPath; + action: Action; +}): Promise { + return findCommitSHAForExactMessage(appDir, action.id); +} + +export async function generatePatchForAction({ + appDir, + action, +}: { + appDir: AppDirPath; + action: ApplyPatchAction; +}): Promise { + const actionCommitSha = await getActionCommitSHA({ appDir, action }); + return generatePatchFromRevision(appDir, actionCommitSha); +} + +export async function applyPatchForAction({ + appDir, + action, +}: { + appDir: AppDirPath; + action: ApplyPatchAction; +}): Promise { + await applyPatch(appDir, action.patchFilePath); +} + +export async function regeneratePatchForAction({ + appDir, + action, +}: { + appDir: AppDirPath; + action: ApplyPatchAction; +}): Promise { + log("info", `Trying to fix patch for step: ${action.displayName}`); + + if (await fs.pathExists(action.patchFilePath)) { + log("info", `Removing existing patch file: ${action.patchFilePath}`); + await fs.unlink(action.patchFilePath); + } + + await askUserToEditAndCreatePatch({ appDir, action }); +} + +export async function askUserToEditAndCreatePatch({ + appDir, + action, +}: { + appDir: AppDirPath; + action: ApplyPatchAction; +}) { + await askToOpenProjectInEditor(); + await confirm({ + message: `Do the step ${action.displayName} and press Enter`, + }); + + const patch = await generatePatchFromAllChanges(appDir); + assertValidPatch(patch); + await fs.writeFile(action.patchFilePath, patch, "utf-8"); + + log("success", `Patch file created: ${action.patchFilePath}`); +} + +export async function assertValidPatch(patch: string): Promise { + const parsedPatch = parseGitDiff(patch); + + if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { + throw new Error("Invalid patch: no changes found"); + } +} + +export async function createBranchFromActionCommit({ + appDir, + branchName, + action, +}: { + appDir: AppDirPath; + branchName: string; + action: ApplyPatchAction; +}): Promise { + const actionCommitSha = await getActionCommitSHA({ appDir, action }); + await createBranchFromRevision({ + gitRepoDir: appDir, + branchName, + revision: actionCommitSha, + }); +} diff --git a/web/tutorial-app-generator/src/executeSteps/actions.ts b/web/tutorial-app-generator/src/actions/index.ts similarity index 100% rename from web/tutorial-app-generator/src/executeSteps/actions.ts rename to web/tutorial-app-generator/src/actions/index.ts diff --git a/web/tutorial-app-generator/src/commands/edit-step/index.ts b/web/tutorial-app-generator/src/commands/edit-step/index.ts index b22633ac27..71a3023678 100644 --- a/web/tutorial-app-generator/src/commands/edit-step/index.ts +++ b/web/tutorial-app-generator/src/commands/edit-step/index.ts @@ -1,13 +1,20 @@ import fs from "fs/promises"; import { Command, Option } from "@commander-js/extra-typings"; -import { $, ProcessOutput } from "zx"; +import { ProcessOutput } from "zx"; import { confirm, select } from "@inquirer/prompts"; +import type { Action, ApplyPatchAction } from "../../actions"; +import { + applyPatchForAction, + askUserToEditAndCreatePatch, + commitActionChanges, + createBranchFromActionCommit, + generatePatchForAction, +} from "../../actions/git"; import type { AppDirPath } from "../../brandedTypes"; -import type { Action, ApplyPatchAction } from "../../executeSteps/actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; -import { generatePatchFromRevision } from "../../git"; +import { moveLastCommitChangesToStaging, rebaseBranch } from "../../git"; import { log } from "../../log"; import { appDir, mainBranchName, tutorialDir } from "../../project"; import { generateApp } from "../generate-app"; @@ -39,37 +46,39 @@ export const editStepCommand = new Command("edit-step") log("info", `Editing step ${action.displayName}...`); - await editStepPatch({ appDir, action }); + await editActionPatch({ appDir, action }); await extractCommitsIntoPatches(actions); log("success", `Edit completed for step ${action.displayName}!`); }); -async function editStepPatch({ +async function editActionPatch({ appDir, action, }: { appDir: AppDirPath; action: ApplyPatchAction; }): Promise { - await $({ cwd: appDir })`git switch ${mainBranchName}`; - const fixesBranchName = "fixes"; - await $({ - cwd: appDir, - })`git switch --force-create ${fixesBranchName} ${action.id}`; - await confirm({ - message: `Do the step ${action.displayName} (go into the docs and read what needs to be done) and press Enter`, + await createBranchFromActionCommit({ + appDir, + branchName: fixesBranchName, + action, }); - await $({ cwd: appDir })`git add .`; - await $({ cwd: appDir })`git commit --amend --no-edit`; - await $({ cwd: appDir })`git tag -f ${action.id}`; - await $({ cwd: appDir })`git switch ${mainBranchName}`; + await moveLastCommitChangesToStaging(appDir); + await askUserToEditAndCreatePatch({ appDir, action }); + await applyPatchForAction({ appDir, action }); + await commitActionChanges({ appDir, action }); + try { - await $({ cwd: appDir, throw: false })`git rebase ${fixesBranchName}`; + await rebaseBranch({ + gitRepoDir: appDir, + branchName: fixesBranchName, + baseBranchName: mainBranchName, + }); } catch (error: unknown) { if ( error instanceof ProcessOutput && @@ -91,7 +100,7 @@ async function extractCommitsIntoPatches(actions: Action[]): Promise { for (const action of applyPatchActions) { log("info", `Updating patch for step ${action.displayName}`); - const patch = await generatePatchFromRevision(appDir, action.id); + const patch = await generatePatchForAction({ appDir, action }); await fs.writeFile(action.patchFilePath, patch, "utf-8"); } } diff --git a/web/tutorial-app-generator/src/executeSteps/index.ts b/web/tutorial-app-generator/src/commands/generate-app/execute-steps.ts similarity index 62% rename from web/tutorial-app-generator/src/executeSteps/index.ts rename to web/tutorial-app-generator/src/commands/generate-app/execute-steps.ts index 10ca73a259..f22b12ba38 100644 --- a/web/tutorial-app-generator/src/executeSteps/index.ts +++ b/web/tutorial-app-generator/src/commands/generate-app/execute-steps.ts @@ -1,11 +1,14 @@ import { chalk, fs } from "zx"; -import type { AppDirPath, PatchesDirPath } from "../brandedTypes"; -import { tagAllChanges } from "../git"; -import { log } from "../log"; -import { waspDbMigrate } from "../waspCli"; -import { type Action } from "./actions"; -import { applyPatch, tryToFixPatch } from "./patch"; +import { type Action } from "../../actions"; +import { + applyPatchForAction, + commitActionChanges, + regeneratePatchForAction, +} from "../../actions/git"; +import type { AppDirPath, PatchesDirPath } from "../../brandedTypes"; +import { log } from "../../log"; +import { waspDbMigrate } from "../../waspCli"; export async function executeSteps({ appDir, @@ -25,14 +28,14 @@ export async function executeSteps({ switch (action.kind) { case "apply-patch": try { - await applyPatch({ appDir, action }); + await applyPatchForAction({ appDir, action }); } catch (err) { log( "error", `Failed to apply patch for step ${action.displayName}:\n${err}`, ); - await tryToFixPatch({ appDir, action }); - await applyPatch({ appDir, action }); + await regeneratePatchForAction({ appDir, action }); + await applyPatchForAction({ appDir, action }); } break; case "migrate-db": @@ -45,6 +48,6 @@ export async function executeSteps({ log("error", `Error in step with ID ${action.id}:\n\n${err}`); process.exit(1); } - await tagAllChanges(appDir, action.id); + await commitActionChanges({ appDir, action }); } } diff --git a/web/tutorial-app-generator/src/commands/generate-app/index.ts b/web/tutorial-app-generator/src/commands/generate-app/index.ts index 51f00dc8d4..798c021efe 100644 --- a/web/tutorial-app-generator/src/commands/generate-app/index.ts +++ b/web/tutorial-app-generator/src/commands/generate-app/index.ts @@ -1,9 +1,8 @@ import { Command } from "@commander-js/extra-typings"; -import { $ } from "zx"; +import { $, spinner } from "zx"; +import type { Action } from "../../actions"; import type { AppDirPath, AppName, AppParentDirPath } from "../../brandedTypes"; -import { executeSteps } from "../../executeSteps"; -import type { Action } from "../../executeSteps/actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; import { initGitRepo } from "../../git"; import { log } from "../../log"; @@ -16,6 +15,7 @@ import { tutorialDir, } from "../../project"; import { waspNew } from "../../waspCli"; +import { executeSteps } from "./execute-steps"; export const generateAppCommand = new Command("generate-app") .description("Generate a new Wasp app based on the tutorial steps") @@ -27,12 +27,14 @@ export const generateAppCommand = new Command("generate-app") }); export async function generateApp(actions: Action[]): Promise { - await prepareApp({ appDir, appParentDir, appName, mainBranchName }); + await spinner("Initializing the tutorial app...", () => + initApp({ appDir, appParentDir, appName, mainBranchName }), + ); await executeSteps({ appDir, patchesDir, actions }); log("success", `Tutorial app has been successfully generated in ${appDir}`); } -async function prepareApp({ +async function initApp({ appName, appParentDir, appDir, diff --git a/web/tutorial-app-generator/src/commands/list-steps/index.ts b/web/tutorial-app-generator/src/commands/list-steps/index.ts index 111b7ca4a9..069a5e6afc 100644 --- a/web/tutorial-app-generator/src/commands/list-steps/index.ts +++ b/web/tutorial-app-generator/src/commands/list-steps/index.ts @@ -2,7 +2,7 @@ import { Command } from "@commander-js/extra-typings"; import { basename } from "path"; import { chalk } from "zx"; -import type { Action } from "../../executeSteps/actions"; +import type { Action } from "../../actions"; import { getActionsFromTutorialFiles } from "../../extractSteps"; import { tutorialDir } from "../../project"; diff --git a/web/tutorial-app-generator/src/editor.ts b/web/tutorial-app-generator/src/editor.ts new file mode 100644 index 0000000000..7f680d3b76 --- /dev/null +++ b/web/tutorial-app-generator/src/editor.ts @@ -0,0 +1,13 @@ +import { confirm } from "@inquirer/prompts"; +import { $ } from "zx"; + +import { appDir } from "./project"; + +export async function askToOpenProjectInEditor() { + const wantsToOpenVSCode = await confirm({ + message: `Do you want to open the app in VS Code?`, + }); + if (wantsToOpenVSCode) { + await $`code ${appDir}`; + } +} diff --git a/web/tutorial-app-generator/src/executeSteps/patch.ts b/web/tutorial-app-generator/src/executeSteps/patch.ts deleted file mode 100644 index 49bb2d3780..0000000000 --- a/web/tutorial-app-generator/src/executeSteps/patch.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { confirm } from "@inquirer/prompts"; -import parseGitDiff from "parse-git-diff"; -import { $, fs } from "zx"; - -import type { AppDirPath } from "../brandedTypes"; -import { generatePatchFromAllChanges } from "../git"; -import { log } from "../log"; -import type { ApplyPatchAction } from "./actions"; - -export async function applyPatch({ - appDir, - action, -}: { - appDir: AppDirPath; - action: ApplyPatchAction; -}) { - await $({ cwd: appDir })`git apply ${action.patchFilePath} --verbose`.quiet( - true, - ); -} - -export async function tryToFixPatch({ - appDir, - action, -}: { - appDir: AppDirPath; - action: ApplyPatchAction; -}): Promise { - log("info", `Trying to fix patch for step: ${action.displayName}`); - - if (await fs.pathExists(action.patchFilePath)) { - log("info", `Removing existing patch file: ${action.patchFilePath}`); - await fs.unlink(action.patchFilePath); - } - - await createPatchForStep({ appDir, action }); -} - -export async function createPatchForStep({ - appDir, - action, -}: { - appDir: AppDirPath; - action: ApplyPatchAction; -}) { - const wantsToOpenVSCode = await confirm({ - message: `Do you want to open the app in VS Code to make changes for step "${action.displayName}"?`, - }); - if (wantsToOpenVSCode) { - await $`code ${appDir}`; - } - await confirm({ - message: `Do the step ${action.displayName} and press Enter`, - }); - - const patch = await generatePatchFromAllChanges(appDir); - assertValidPatch(patch); - await fs.writeFile(action.patchFilePath, patch, "utf-8"); - - log("success", `Patch file created: ${action.patchFilePath}`); -} - -export async function assertValidPatch(patch: string): Promise { - const parsedPatch = parseGitDiff(patch); - - if (parsedPatch.files.length === 0 || parsedPatch.files[0] === undefined) { - throw new Error("Invalid patch: no changes found"); - } -} diff --git a/web/tutorial-app-generator/src/extractSteps/index.ts b/web/tutorial-app-generator/src/extractSteps/index.ts index 937ecabc92..600ee60854 100644 --- a/web/tutorial-app-generator/src/extractSteps/index.ts +++ b/web/tutorial-app-generator/src/extractSteps/index.ts @@ -7,17 +7,17 @@ import { mdxJsxFromMarkdown, type MdxJsxFlowElement } from "mdast-util-mdx-jsx"; import { mdxJsx } from "micromark-extension-mdx-jsx"; import { visit } from "unist-util-visit"; -import type { - MarkdownFilePath, - StepId, - TutorialDirPath, -} from "../brandedTypes.js"; import { createApplyPatchAction, createMigrateDbAction, type Action, type ActionCommon, -} from "../executeSteps/actions.js"; +} from "../actions/index.js"; +import type { + MarkdownFilePath, + StepId, + TutorialDirPath, +} from "../brandedTypes.js"; export async function getActionsFromTutorialFiles( tutorialDir: TutorialDirPath, diff --git a/web/tutorial-app-generator/src/git.ts b/web/tutorial-app-generator/src/git.ts index 5eb240162e..08beced553 100644 --- a/web/tutorial-app-generator/src/git.ts +++ b/web/tutorial-app-generator/src/git.ts @@ -1,13 +1,5 @@ import { $ } from "zx"; -import { log } from "./log"; - -export async function tagAllChanges(gitRepoDir: string, tagName: string) { - await commitAllChanges(gitRepoDir, `Changes for tag: ${tagName}`); - await $({ cwd: gitRepoDir })`git tag ${tagName} -m ${tagName}`; - log("info", `Tagged changes with "${tagName}"`); -} - export async function generatePatchFromAllChanges( gitRepoDir: string, ): Promise { @@ -17,7 +9,49 @@ export async function generatePatchFromAllChanges( return patch; } -async function commitAllChanges( +export async function applyPatch(gitRepoDir: string, patchPath: string) { + await $({ cwd: gitRepoDir })`git apply ${patchPath} --verbose`.quiet(true); +} + +export async function findCommitSHAForExactMessage( + gitRepoDir: string, + message: string, +): Promise { + const commits = await grepGitCommitMessages(gitRepoDir, message); + + const commit = commits.find((commit) => commit.message === message); + if (!commit) { + throw new Error(`No commit found with message: "${message}"`); + } + + return commit.sha; +} + +type GitCommit = { + message: string; + sha: string; +}; + +async function grepGitCommitMessages( + gitRepoDir: string, + message: string, +): Promise { + const format = `{"message":"%s","sha":"%H"}`; + const { stdout } = await $({ + cwd: gitRepoDir, + })`git log --branches --format=${format} --grep=${message}`; + + const commits = stdout.split("\n").filter((line) => line.trim() !== ""); + return commits.map((commit) => { + const parsed = JSON.parse(commit); + return { + message: parsed.message, + sha: parsed.sha, + }; + }); +} + +export async function commitAllChanges( gitRepoDir: string, message: string, ): Promise { @@ -49,3 +83,36 @@ export async function initGitRepo( await $({ cwd: gitRepoDir })`git add .`; await $({ cwd: gitRepoDir })`git commit -m "Initial commit"`; } + +export async function createBranchFromRevision({ + gitRepoDir, + branchName, + revision, +}: { + gitRepoDir: string; + branchName: string; + revision: string; +}): Promise { + await $({ + cwd: gitRepoDir, + })`git switch --force-create ${branchName} ${revision}`; +} + +export async function moveLastCommitChangesToStaging( + gitRepoDir: string, +): Promise { + await $({ cwd: gitRepoDir })`git reset --soft HEAD~1`; +} + +export async function rebaseBranch({ + gitRepoDir, + branchName, + baseBranchName, +}: { + gitRepoDir: string; + branchName: string; + baseBranchName: string; +}): Promise { + await $({ cwd: gitRepoDir })`git switch ${baseBranchName}`; + await $({ cwd: gitRepoDir })`git rebase ${branchName}`; +} From dc7fd215dc26b383e16c340f69109eaaf64179d7 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 8 Aug 2025 16:55:33 +0200 Subject: [PATCH 28/48] Rename folder and update README --- web/tutorial-app-generator/README.md | 126 -------------- .../.gitignore | 0 web/tutorial-steps-executor/README.md | 158 ++++++++++++++++++ .../package-lock.json | 0 .../package.json | 0 .../src/actions/git.ts | 0 .../src/actions/index.ts | 0 .../src/brandedTypes.ts | 0 .../src/commands/edit-step/index.ts | 0 .../commands/generate-app/execute-steps.ts | 0 .../src/commands/generate-app/index.ts | 0 .../src/commands/list-steps/index.ts | 0 .../src/editor.ts | 0 .../src/extractSteps/index.ts | 0 .../src/files.ts | 0 .../src/git.ts | 0 .../src/index.ts | 0 .../src/log.ts | 0 .../src/project.ts | 0 .../src/waspCli.ts | 0 .../tsconfig.json | 0 21 files changed, 158 insertions(+), 126 deletions(-) delete mode 100644 web/tutorial-app-generator/README.md rename web/{tutorial-app-generator => tutorial-steps-executor}/.gitignore (100%) create mode 100644 web/tutorial-steps-executor/README.md rename web/{tutorial-app-generator => tutorial-steps-executor}/package-lock.json (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/package.json (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/actions/git.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/actions/index.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/brandedTypes.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/commands/edit-step/index.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/commands/generate-app/execute-steps.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/commands/generate-app/index.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/commands/list-steps/index.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/editor.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/extractSteps/index.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/files.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/git.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/index.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/log.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/project.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/src/waspCli.ts (100%) rename web/{tutorial-app-generator => tutorial-steps-executor}/tsconfig.json (100%) diff --git a/web/tutorial-app-generator/README.md b/web/tutorial-app-generator/README.md deleted file mode 100644 index a675052254..0000000000 --- a/web/tutorial-app-generator/README.md +++ /dev/null @@ -1,126 +0,0 @@ -# Tutorial Steps Executor (tutse) - -A CLI tool that generates Wasp tutorial applications by parsing markdown files containing tutorial steps and automatically applying patches and database migrations. - -## Overview - -This tool reads tutorial markdown files, extracts `` components, and executes the corresponding actions to build a complete tutorial app step by step. Each step is tracked with git commits and tags for easy navigation and editing. - -## Setup - -```bash -npm install -``` - -## Commands - -### Generate Tutorial App - -Generates a complete tutorial app by executing all tutorial steps in sequence: - -```bash -npm run generate-app -``` - -This command: -1. Creates a new Wasp app using `wasp new` -2. Initializes a git repository with proper branching -3. Parses all tutorial markdown files from the tutorial directory -4. Executes actions (patches, migrations) in order -5. Creates git tags for each completed step -6. Outputs the final app in the configured app directory - -### List Tutorial Steps - -Display all available tutorial steps organized by source file: - -```bash -npm run list-steps -``` - -### Edit Tutorial Step - -Edit a specific tutorial step and regenerate associated patches: - -```bash -npm run edit-step --step-id -# or -npm run edit-step # Interactive step selection -``` - -Options: -- `--step-id `: Specify the step ID to edit -- `--skip-generating-app`: Skip app generation and use existing app - -This command: -1. Generates the app (if not skipped) -2. Switches to the step's git tag -3. Creates a `fixes` branch for editing -4. Opens a new shell in the app directory, allowing you to make code changes. -5. Once you exit the shell, it updates the patch file with your changes. -6. Rebases subsequent commits to incorporate changes - -## Tutorial Markdown Format - -Tutorial steps are defined using `` JSX components within markdown files: - -```jsx - - -``` - -### Required Attributes - -- `id`: Unique identifier for the step (used for git tags and patch file names) -- `action`: Type of action to execute - -### Supported Actions - -#### `apply-patch` -Applies a git patch file to modify the codebase: -- Looks for patch file at `patches/{step-id}.patch` -- Automatically attempts to fix failed patches -- Creates git commit with the changes - -#### `migrate-db` -Runs database migration using Wasp CLI, executes `wasp db migrate-dev`. - -## Tutorial File Organization - -Tutorial markdown files should be: -- Located in the configured tutorial directory -- Named with numeric prefixes for ordering (e.g., `01-setup.md`, `02-auth.md`) -- Contain `` components for executable steps - -Example tutorial file structure: -``` -tutorial/ -├── 01-getting-started.md -├── 02-database-setup.md -├── 03-authentication.md -└── 04-frontend-features.md -``` - -## Configuration - -The tool uses configuration from `src/project.ts`: -- Tutorial directory path -- App output directory -- Patches directory -- Git branch naming - -## Git Integration - -Each tutorial step creates: -- A git commit with the step changes -- A git tag with the step ID for easy navigation -- Proper branch management for editing workflows - -### Rebasing on Edit - -When you edit a tutorial step using `npm run edit-step`, the tool performs a git rebase to update all subsequent steps. Here's how it works: - -1. The tool checks out the commit just before the one you want to edit. -2. You make your changes in a temporary `fixes` branch. -3. A new patch is generated from your changes, and a new commit is created for the edited step. -4. The tool then automatically rebases all subsequent tutorial steps on top of this new commit. This ensures that your changes are propagated through the rest of the tutorial, and all patches and tags are updated accordingly. diff --git a/web/tutorial-app-generator/.gitignore b/web/tutorial-steps-executor/.gitignore similarity index 100% rename from web/tutorial-app-generator/.gitignore rename to web/tutorial-steps-executor/.gitignore diff --git a/web/tutorial-steps-executor/README.md b/web/tutorial-steps-executor/README.md new file mode 100644 index 0000000000..62ae59a2ef --- /dev/null +++ b/web/tutorial-steps-executor/README.md @@ -0,0 +1,158 @@ +# Tutorial Steps Executor + +A CLI tool for managing and editing tutorial steps in Wasp docs. This tool allows you to execute all tutorial steps to get the final app or edit individual steps. + +## Comamnds + +The CLI provides three main commands: + +### 1. Generate App (`npm run generate-app`) + +Creates a complete Wasp application by executing all tutorial steps in sequence. + +```bash +npm run generate-app +``` + +This command: + +- Initializes a new Wasp application +- Reads all tutorial markdown files (numbered like `01-setup.md`, `02-auth.md`, etc.) +- Extracts `` components from the markdown +- Applies each step's patches or database migrations in order +- Creates a Git commit for each step +- Results in a fully functional application with complete Git history + +### 2. Edit Step (`npm run edit-step`) + +Allows you to modify a specific tutorial step and automatically update all subsequent steps. + +```bash +npm run edit-step --step-id "create-task-entity" +# or interactive mode: +npm run edit-step +``` + +This command: + +- Generates the app (unless `--skip-generating-app` is used) +- Creates a branch from the step's commit +- Captures your changes as a new patch +- Rebases the changes through all subsequent steps +- Updates all affected patch files + +### 3. List Steps (`npm run list-steps`) + +Displays all available tutorial steps organized by source file. + +```bash +npm run list-steps +``` + +## How It Works on Git Level, Patches, and Rebasing + +The tool uses a Git-based workflow to manage tutorial steps: + +### Executing Tutorial Steps + +1. **Initial Setup**: Creates a Git repository with an initial commit +2. **Step Execution**: Each step is executed and commited as a separate Git commit + with the step ID as the commit message + +### Step Editing Process + +When editing a tutorial step (e.g., step 4 out of 10 total steps): + +#### Phase 1: Setup and Branching + +```bash +# Generate app with all 10 steps, each as a commit +git init +git commit -m "Initial commit" +git commit -m "step-1-setup" +git commit -m "step-2-auth" +git commit -m "step-3-database" +git commit -m "step-4-create-task-entity" # ← Target step +# ... and so on + +# Create branch from step 4's commit +git switch --force-create fixes +``` + +#### Phase 2: User Editing + +```bash +# Move the step 4 commit changes to staging area +git reset --soft HEAD~1 + +# User makes their edits in the editor +# User confirms they're done +``` + +#### Phase 3: Patch Creation and Application + +```bash +# Commit all current changes and generate a new patch +git add . +git commit -m "temporary-commit" +git show HEAD --format= > new-patch.patch +git reset --hard HEAD~1 + +# Apply the new patch and commit with original step ID +git apply new-patch.patch +git commit -m "step-4-create-task-entity" +``` + +#### Phase 4: Rebasing and Integration + +```bash +# Switch back to main branch and rebase the fixes +git switch main +git rebase fixes # This integrates the fixed step 4 + +# If conflicts occur, user resolves them like any other Git conflict +``` + +#### Phase 5: Patch File Updates + +```bash +# Regenerate all patch files from the updated commits +# For each step after the edited one: +git show step-commit-sha --format= > patches/step-N.patch +``` + +### Patch File Management + +- Patch files are stored in the `./docs/tutorial/patches/` directory +- Files are named after their step IDs (e.g., `create-task-entity.patch`) +- Contains the Git diff for that specific step +- When a step is edited, all subsequent patch files are automatically regenerated + +### Database Migration Handling + +For steps that involve database changes: + +- Uses `wasp db migrate` command instead of patch application +- Migration files are committed with the step ID as the commit message + +### Tutorial File Format + +Tutorial steps are defined in markdown files using JSX-like components: + +```markdown +# Step 4: Create Task Entity + + + +In this step, we'll create a Task entity... +``` + +The tool extracts these components and uses: + +- `id`: Unique identifier for the step (becomes commit message) +- `action`: Type of action (`apply-patch` or `migrate-db`) + +This Git-based approach ensures that: + +- **Changes can be made to any step** without breaking subsequent steps +- **Conflicts are automatically handled** by Git's rebasing mechanism diff --git a/web/tutorial-app-generator/package-lock.json b/web/tutorial-steps-executor/package-lock.json similarity index 100% rename from web/tutorial-app-generator/package-lock.json rename to web/tutorial-steps-executor/package-lock.json diff --git a/web/tutorial-app-generator/package.json b/web/tutorial-steps-executor/package.json similarity index 100% rename from web/tutorial-app-generator/package.json rename to web/tutorial-steps-executor/package.json diff --git a/web/tutorial-app-generator/src/actions/git.ts b/web/tutorial-steps-executor/src/actions/git.ts similarity index 100% rename from web/tutorial-app-generator/src/actions/git.ts rename to web/tutorial-steps-executor/src/actions/git.ts diff --git a/web/tutorial-app-generator/src/actions/index.ts b/web/tutorial-steps-executor/src/actions/index.ts similarity index 100% rename from web/tutorial-app-generator/src/actions/index.ts rename to web/tutorial-steps-executor/src/actions/index.ts diff --git a/web/tutorial-app-generator/src/brandedTypes.ts b/web/tutorial-steps-executor/src/brandedTypes.ts similarity index 100% rename from web/tutorial-app-generator/src/brandedTypes.ts rename to web/tutorial-steps-executor/src/brandedTypes.ts diff --git a/web/tutorial-app-generator/src/commands/edit-step/index.ts b/web/tutorial-steps-executor/src/commands/edit-step/index.ts similarity index 100% rename from web/tutorial-app-generator/src/commands/edit-step/index.ts rename to web/tutorial-steps-executor/src/commands/edit-step/index.ts diff --git a/web/tutorial-app-generator/src/commands/generate-app/execute-steps.ts b/web/tutorial-steps-executor/src/commands/generate-app/execute-steps.ts similarity index 100% rename from web/tutorial-app-generator/src/commands/generate-app/execute-steps.ts rename to web/tutorial-steps-executor/src/commands/generate-app/execute-steps.ts diff --git a/web/tutorial-app-generator/src/commands/generate-app/index.ts b/web/tutorial-steps-executor/src/commands/generate-app/index.ts similarity index 100% rename from web/tutorial-app-generator/src/commands/generate-app/index.ts rename to web/tutorial-steps-executor/src/commands/generate-app/index.ts diff --git a/web/tutorial-app-generator/src/commands/list-steps/index.ts b/web/tutorial-steps-executor/src/commands/list-steps/index.ts similarity index 100% rename from web/tutorial-app-generator/src/commands/list-steps/index.ts rename to web/tutorial-steps-executor/src/commands/list-steps/index.ts diff --git a/web/tutorial-app-generator/src/editor.ts b/web/tutorial-steps-executor/src/editor.ts similarity index 100% rename from web/tutorial-app-generator/src/editor.ts rename to web/tutorial-steps-executor/src/editor.ts diff --git a/web/tutorial-app-generator/src/extractSteps/index.ts b/web/tutorial-steps-executor/src/extractSteps/index.ts similarity index 100% rename from web/tutorial-app-generator/src/extractSteps/index.ts rename to web/tutorial-steps-executor/src/extractSteps/index.ts diff --git a/web/tutorial-app-generator/src/files.ts b/web/tutorial-steps-executor/src/files.ts similarity index 100% rename from web/tutorial-app-generator/src/files.ts rename to web/tutorial-steps-executor/src/files.ts diff --git a/web/tutorial-app-generator/src/git.ts b/web/tutorial-steps-executor/src/git.ts similarity index 100% rename from web/tutorial-app-generator/src/git.ts rename to web/tutorial-steps-executor/src/git.ts diff --git a/web/tutorial-app-generator/src/index.ts b/web/tutorial-steps-executor/src/index.ts similarity index 100% rename from web/tutorial-app-generator/src/index.ts rename to web/tutorial-steps-executor/src/index.ts diff --git a/web/tutorial-app-generator/src/log.ts b/web/tutorial-steps-executor/src/log.ts similarity index 100% rename from web/tutorial-app-generator/src/log.ts rename to web/tutorial-steps-executor/src/log.ts diff --git a/web/tutorial-app-generator/src/project.ts b/web/tutorial-steps-executor/src/project.ts similarity index 100% rename from web/tutorial-app-generator/src/project.ts rename to web/tutorial-steps-executor/src/project.ts diff --git a/web/tutorial-app-generator/src/waspCli.ts b/web/tutorial-steps-executor/src/waspCli.ts similarity index 100% rename from web/tutorial-app-generator/src/waspCli.ts rename to web/tutorial-steps-executor/src/waspCli.ts diff --git a/web/tutorial-app-generator/tsconfig.json b/web/tutorial-steps-executor/tsconfig.json similarity index 100% rename from web/tutorial-app-generator/tsconfig.json rename to web/tutorial-steps-executor/tsconfig.json From 4aa11730cec96c2f71212d0a2b3487ffb43881f4 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 12 Sep 2025 13:55:34 +0200 Subject: [PATCH 29/48] PR comments Signed-off-by: Mihovil Ilakovac --- web/tutorial-actions-executor/.gitignore | 2 + web/tutorial-actions-executor/README.md | 163 ++++++++++++++++++ .../package-lock.json | 0 .../package.json | 4 +- .../src/actions/actions.ts | 23 +++ .../src/actions/git.ts | 4 +- .../src/actions/index.ts | 29 +--- .../src/brandedTypes.ts | 3 + .../src/commands/edit-action}/index.ts | 67 ++++--- .../commands/generate-app/execute-actions.ts} | 12 +- .../src/commands/generate-app/index.ts | 69 ++++++++ .../src/commands/list-actions}/index.ts | 12 +- web/tutorial-actions-executor/src/editor.ts | 16 ++ .../src/extract-actions}/index.ts | 20 +-- .../src/files.ts | 0 .../src/git.ts | 2 + .../src/index.ts | 8 +- .../src/log.ts | 0 .../src/tutorialApp.ts | 25 +++ .../src/waspCli.ts | 0 .../tsconfig.json | 0 web/tutorial-steps-executor/.gitignore | 2 - web/tutorial-steps-executor/README.md | 158 ----------------- .../src/brandedTypes.ts | 12 -- .../src/commands/generate-app/index.ts | 52 ------ web/tutorial-steps-executor/src/editor.ts | 13 -- web/tutorial-steps-executor/src/project.ts | 19 -- 27 files changed, 381 insertions(+), 334 deletions(-) create mode 100644 web/tutorial-actions-executor/.gitignore create mode 100644 web/tutorial-actions-executor/README.md rename web/{tutorial-steps-executor => tutorial-actions-executor}/package-lock.json (100%) rename web/{tutorial-steps-executor => tutorial-actions-executor}/package.json (88%) create mode 100644 web/tutorial-actions-executor/src/actions/actions.ts rename web/{tutorial-steps-executor => tutorial-actions-executor}/src/actions/git.ts (94%) rename web/{tutorial-steps-executor => tutorial-actions-executor}/src/actions/index.ts (54%) create mode 100644 web/tutorial-actions-executor/src/brandedTypes.ts rename web/{tutorial-steps-executor/src/commands/edit-step => tutorial-actions-executor/src/commands/edit-action}/index.ts (59%) rename web/{tutorial-steps-executor/src/commands/generate-app/execute-steps.ts => tutorial-actions-executor/src/commands/generate-app/execute-actions.ts} (73%) create mode 100644 web/tutorial-actions-executor/src/commands/generate-app/index.ts rename web/{tutorial-steps-executor/src/commands/list-steps => tutorial-actions-executor/src/commands/list-actions}/index.ts (79%) create mode 100644 web/tutorial-actions-executor/src/editor.ts rename web/{tutorial-steps-executor/src/extractSteps => tutorial-actions-executor/src/extract-actions}/index.ts (92%) rename web/{tutorial-steps-executor => tutorial-actions-executor}/src/files.ts (100%) rename web/{tutorial-steps-executor => tutorial-actions-executor}/src/git.ts (98%) rename web/{tutorial-steps-executor => tutorial-actions-executor}/src/index.ts (50%) rename web/{tutorial-steps-executor => tutorial-actions-executor}/src/log.ts (100%) create mode 100644 web/tutorial-actions-executor/src/tutorialApp.ts rename web/{tutorial-steps-executor => tutorial-actions-executor}/src/waspCli.ts (100%) rename web/{tutorial-steps-executor => tutorial-actions-executor}/tsconfig.json (100%) delete mode 100644 web/tutorial-steps-executor/.gitignore delete mode 100644 web/tutorial-steps-executor/README.md delete mode 100644 web/tutorial-steps-executor/src/brandedTypes.ts delete mode 100644 web/tutorial-steps-executor/src/commands/generate-app/index.ts delete mode 100644 web/tutorial-steps-executor/src/editor.ts delete mode 100644 web/tutorial-steps-executor/src/project.ts diff --git a/web/tutorial-actions-executor/.gitignore b/web/tutorial-actions-executor/.gitignore new file mode 100644 index 0000000000..d7e24f8462 --- /dev/null +++ b/web/tutorial-actions-executor/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.generated-apps/ diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md new file mode 100644 index 0000000000..73484bff2c --- /dev/null +++ b/web/tutorial-actions-executor/README.md @@ -0,0 +1,163 @@ +# Tutorial Actions Executor (tacte) + +A CLI tool for managing and editing tutorial actions in Wasp docs. This tool allows you to execute all tutorial actions to get the final app or edit individual actions. + +## Comamnds + +The CLI provides three main commands: + +### 1. Generate App (`npm run generate-app`) + +Creates a complete Wasp application by executing all tutorial actions in sequence. + +```bash +npm run generate-app +``` + +This command: + +- Initializes a new Wasp application +- Reads all tutorial markdown files (numbered like `01-setup.md`, `02-auth.md`, etc.) +- Extracts `` components from the markdown +- Applies each action's patches or database migrations in order +- Creates a Git commit for each action +- Results in a fully functional application. + +### 2. Edit Action (`npm run edit-action`) + +Allows you to modify a specific tutorial action and automatically update all subsequent actions. + +```bash +npm run edit-action --action-id "create-task-entity" +# or interactive mode: +npm run edit-action +``` + +This command: + +- Generates the app (unless `--skip-generating-app` is used) +- Creates a branch from the action's commit +- Captures your changes as a new patch +- Updates all affected patch files + +### 3. List Actions (`npm run list-actions`) + +Displays all available tutorial actions organized by source file. + +```bash +npm run list-actions +``` + + +### Patch File Management + +- Patch files are stored in the `./docs/tutorial/patches/` directory +- Files are named after their action IDs (e.g., `create-task-entity.patch`) +- Contains the Git diff for that specific action +- When a action is edited, all subsequent patch files are automatically regenerated + +### Database Migration Handling + +For actions that involve database changes: + +- Uses `wasp db migrate` command instead of patch application +- Migration files are committed with the action ID as the commit message + +### Tutorial File Format + +Tutorial actions are defined in markdown files using JSX-like components: + +````markdown +# Step 4: Create Task Entity + + + +In this action, we'll create the Task entity: +```prisma +model Task { + id Int @id @default(autoincrement()) +} +``` +```` + +The tool extracts these components and uses: + +- `id`: Unique identifier for the action (becomes commit message) +- `action`: Type of action (`apply-patch` or `migrate-db`) + +This Git-based approach ensures that: + +- **Changes can be made to any action** without breaking subsequent actions +- **Conflicts are automatically handled** by Git's rebasing mechanism + +## How It Works on Git Level, Patches, and Rebasing + +The tool uses a Git-based workflow to manage tutorial actions: + +### Executing Tutorial Actions + +1. **Initial Setup**: Creates a Git repository with an initial commit +2. **Action Execution**: Each action is executed and commited as a separate Git commit + with the action ID as the commit message + +### Action Editing Process + +When editing a tutorial action (e.g., action 4 out of 10 total actions): + +#### Phase 1: Setup and Branching + +```bash +# Generate app with all 10 actions, each as a commit +git init +git commit -m "Initial commit" +git commit -m "action-1-setup" +git commit -m "action-2-auth" +git commit -m "action-3-database" +git commit -m "action-4-create-task-entity" # ← Target action +# ... and so on + +# Create branch from action 4's commit +git switch --force-create fixes +``` + +#### Phase 2: User Editing + +```bash +# Move the action 4 commit changes to staging area +git reset --soft HEAD~1 + +# User makes their edits in the editor +# User confirms they're done +``` + +#### Phase 3: Patch Creation and Application + +```bash +# Commit all current changes and generate a new patch +git add . +git commit -m "temporary-commit" +git show HEAD --format= > new-patch.patch +git reset --hard HEAD~1 + +# Apply the new patch and commit with original action ID +git apply new-patch.patch +git commit -m "action-4-create-task-entity" +``` + +#### Phase 4: Rebasing and Integration + +```bash +# Switch back to main branch and rebase the fixes +git switch main +git rebase fixes # This integrates the fixed action 4 + +# If conflicts occur, user resolves them like any other Git conflict +``` + +#### Phase 5: Patch File Updates + +```bash +# Regenerate all patch files from the updated commits +# For each action after the edited one: +git show action-commit-sha --format= > patches/action-N.patch +``` \ No newline at end of file diff --git a/web/tutorial-steps-executor/package-lock.json b/web/tutorial-actions-executor/package-lock.json similarity index 100% rename from web/tutorial-steps-executor/package-lock.json rename to web/tutorial-actions-executor/package-lock.json diff --git a/web/tutorial-steps-executor/package.json b/web/tutorial-actions-executor/package.json similarity index 88% rename from web/tutorial-steps-executor/package.json rename to web/tutorial-actions-executor/package.json index baa39f1970..9b50063f88 100644 --- a/web/tutorial-steps-executor/package.json +++ b/web/tutorial-actions-executor/package.json @@ -4,9 +4,9 @@ "type": "module", "license": "MIT", "scripts": { - "edit-step": "tsx ./src/index.ts edit-step", + "edit-action": "tsx ./src/index.ts edit-action", "generate-app": "tsx ./src/index.ts generate-app", - "list-steps": "tsx ./src/index.ts list-steps" + "list-actions": "tsx ./src/index.ts list-actions" }, "dependencies": { "@commander-js/extra-typings": "^14.0.0", diff --git a/web/tutorial-actions-executor/src/actions/actions.ts b/web/tutorial-actions-executor/src/actions/actions.ts new file mode 100644 index 0000000000..2d70ca3864 --- /dev/null +++ b/web/tutorial-actions-executor/src/actions/actions.ts @@ -0,0 +1,23 @@ +import type { Branded } from "../brandedTypes"; + +// If modify or add new action kinds, make sure to also update the type in `docs/tutorial/TutorialAction.tsx`. +export type Action = ApplyPatchAction | MigrateDbAction; + +export type ApplyPatchAction = { + kind: "apply-patch"; + displayName: string; + patchFilePath: PatchFilePath; +} & ActionCommon; + +export type MigrateDbAction = { + kind: "migrate-db"; +} & ActionCommon; + +export type ActionCommon = { + id: ActionId; + tutorialFilePath: MarkdownFilePath; +}; + +export type ActionId = Branded; +export type PatchFilePath = Branded; +export type MarkdownFilePath = Branded; diff --git a/web/tutorial-steps-executor/src/actions/git.ts b/web/tutorial-actions-executor/src/actions/git.ts similarity index 94% rename from web/tutorial-steps-executor/src/actions/git.ts rename to web/tutorial-actions-executor/src/actions/git.ts index cc7f984a44..8ac7c94030 100644 --- a/web/tutorial-steps-executor/src/actions/git.ts +++ b/web/tutorial-actions-executor/src/actions/git.ts @@ -63,7 +63,7 @@ export async function regeneratePatchForAction({ appDir: AppDirPath; action: ApplyPatchAction; }): Promise { - log("info", `Trying to fix patch for step: ${action.displayName}`); + log("info", `Trying to fix patch for action: ${action.displayName}`); if (await fs.pathExists(action.patchFilePath)) { log("info", `Removing existing patch file: ${action.patchFilePath}`); @@ -82,7 +82,7 @@ export async function askUserToEditAndCreatePatch({ }) { await askToOpenProjectInEditor(); await confirm({ - message: `Do the step ${action.displayName} and press Enter`, + message: `Update the app according to action ${action.displayName} and press Enter`, }); const patch = await generatePatchFromAllChanges(appDir); diff --git a/web/tutorial-steps-executor/src/actions/index.ts b/web/tutorial-actions-executor/src/actions/index.ts similarity index 54% rename from web/tutorial-steps-executor/src/actions/index.ts rename to web/tutorial-actions-executor/src/actions/index.ts index 5d381781ee..6733166519 100644 --- a/web/tutorial-steps-executor/src/actions/index.ts +++ b/web/tutorial-actions-executor/src/actions/index.ts @@ -1,25 +1,12 @@ import path, { basename } from "path"; -import type { MarkdownFilePath, PatchFilePath, StepId } from "../brandedTypes"; import { getFileNameWithoutExtension } from "../files"; -import { patchesDir } from "../project"; - -export type ActionCommon = { - id: StepId; - tutorialFilePath: MarkdownFilePath; -}; - -// If modify or add new action kinds, make sure to also update the type in `docs/tutorial/TutorialAction.tsx`. -export type ApplyPatchAction = { - kind: "apply-patch"; - displayName: string; - patchFilePath: PatchFilePath; -} & ActionCommon; - -export type MigrateDbAction = { - kind: "migrate-db"; -} & ActionCommon; - -export type Action = ApplyPatchAction | MigrateDbAction; +import { docsTutorialPatchesPath } from "../tutorialApp"; +import type { + ActionCommon, + ApplyPatchAction, + MigrateDbAction, + PatchFilePath, +} from "./actions"; export function createMigrateDbAction( commonData: ActionCommon, @@ -45,5 +32,5 @@ export function createApplyPatchAction( function getPatchFilePath(action: ActionCommon): PatchFilePath { const sourceFileName = getFileNameWithoutExtension(action.tutorialFilePath); const patchFileName = `${sourceFileName}__${action.id}.patch`; - return path.resolve(patchesDir, patchFileName) as PatchFilePath; + return path.resolve(docsTutorialPatchesPath, patchFileName) as PatchFilePath; } diff --git a/web/tutorial-actions-executor/src/brandedTypes.ts b/web/tutorial-actions-executor/src/brandedTypes.ts new file mode 100644 index 0000000000..3e9401f1fb --- /dev/null +++ b/web/tutorial-actions-executor/src/brandedTypes.ts @@ -0,0 +1,3 @@ +declare const __brand: unique symbol; +type Brand = { [__brand]: B }; +export type Branded = T & Brand; diff --git a/web/tutorial-steps-executor/src/commands/edit-step/index.ts b/web/tutorial-actions-executor/src/commands/edit-action/index.ts similarity index 59% rename from web/tutorial-steps-executor/src/commands/edit-step/index.ts rename to web/tutorial-actions-executor/src/commands/edit-action/index.ts index 71a3023678..61edda1d6c 100644 --- a/web/tutorial-steps-executor/src/commands/edit-step/index.ts +++ b/web/tutorial-actions-executor/src/commands/edit-action/index.ts @@ -4,7 +4,7 @@ import { Command, Option } from "@commander-js/extra-typings"; import { ProcessOutput } from "zx"; import { confirm, select } from "@inquirer/prompts"; -import type { Action, ApplyPatchAction } from "../../actions"; +import type { Action, ApplyPatchAction } from "../../actions/actions"; import { applyPatchForAction, askUserToEditAndCreatePatch, @@ -12,45 +12,55 @@ import { createBranchFromActionCommit, generatePatchForAction, } from "../../actions/git"; -import type { AppDirPath } from "../../brandedTypes"; -import { getActionsFromTutorialFiles } from "../../extractSteps"; -import { moveLastCommitChangesToStaging, rebaseBranch } from "../../git"; +import { getActionsFromTutorialFiles } from "../../extract-actions"; +import { + mainBranchName, + moveLastCommitChangesToStaging, + rebaseBranch, +} from "../../git"; import { log } from "../../log"; -import { appDir, mainBranchName, tutorialDir } from "../../project"; +import { + docsTutorialDirPath, + tutorialAppDirPath, + type AppDirPath, +} from "../../tutorialApp"; import { generateApp } from "../generate-app"; -export const editStepCommand = new Command("edit-step") - .description("Edit a step in the tutorial app") - .addOption(new Option("--step-id ", "Name of the step to edit")) +export const editActionCommand = new Command("edit-action") + .description("Edit a action in the tutorial app") + .addOption(new Option("--action-id ", "ID of the action to edit")) .addOption( new Option( "--skip-generating-app", - "Skip generating app before editing step", + "Skip generating app before editing action", ), ) - .action(async ({ stepId, skipGeneratingApp }) => { - const actions = await getActionsFromTutorialFiles(tutorialDir); + .action(async ({ actionId, skipGeneratingApp }) => { + const actions = await getActionsFromTutorialFiles(docsTutorialDirPath); log("info", `Found ${actions.length} actions in tutorial files.`); const action = await ensureAction({ actions, - stepIdOptionValue: stepId, + actionIdOptionValue: actionId, }); if (!skipGeneratingApp) { - log("info", "Generating app before editing step..."); + log("info", "Generating app before editing action..."); await generateApp(actions); } else { - log("info", `Skipping app generation, using existing app in ${appDir}`); + log( + "info", + `Skipping app generation, using existing app in ${tutorialAppDirPath}`, + ); } - log("info", `Editing step ${action.displayName}...`); + log("info", `Editing action ${action.displayName}...`); - await editActionPatch({ appDir, action }); + await editActionPatch({ appDir: tutorialAppDirPath, action }); await extractCommitsIntoPatches(actions); - log("success", `Edit completed for step ${action.displayName}!`); + log("success", `Edit completed for action ${action.displayName}!`); }); async function editActionPatch({ @@ -99,31 +109,34 @@ async function extractCommitsIntoPatches(actions: Action[]): Promise { ); for (const action of applyPatchActions) { - log("info", `Updating patch for step ${action.displayName}`); - const patch = await generatePatchForAction({ appDir, action }); + log("info", `Updating patch for action ${action.displayName}`); + const patch = await generatePatchForAction({ + appDir: tutorialAppDirPath, + action, + }); await fs.writeFile(action.patchFilePath, patch, "utf-8"); } } async function ensureAction({ actions, - stepIdOptionValue, + actionIdOptionValue, }: { actions: Action[]; - stepIdOptionValue: string | undefined; + actionIdOptionValue: string | undefined; }): Promise { const applyPatchActions = actions.filter( (action) => action.kind === "apply-patch", ); - if (!stepIdOptionValue) { + if (!actionIdOptionValue) { return askUserToSelectAction(applyPatchActions); } - const action = applyPatchActions.find((a) => a.id === stepIdOptionValue); + const action = applyPatchActions.find((a) => a.id === actionIdOptionValue); if (!action) { throw new Error( - `Apply patch step with ID "${stepIdOptionValue}" not found.`, + `Apply patch action with ID "${actionIdOptionValue}" not found.`, ); } return action; @@ -132,12 +145,12 @@ async function ensureAction({ async function askUserToSelectAction( actions: ApplyPatchAction[], ): Promise { - const selectedStepId = await select({ - message: "Select a step to edit", + const selectedActionId = await select({ + message: "Select a action to edit", choices: actions.map((action) => ({ name: action.displayName, value: action.id, })), }); - return actions.find((a) => a.id === selectedStepId) as ApplyPatchAction; + return actions.find((a) => a.id === selectedActionId) as ApplyPatchAction; } diff --git a/web/tutorial-steps-executor/src/commands/generate-app/execute-steps.ts b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts similarity index 73% rename from web/tutorial-steps-executor/src/commands/generate-app/execute-steps.ts rename to web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts index f22b12ba38..ac888d12aa 100644 --- a/web/tutorial-steps-executor/src/commands/generate-app/execute-steps.ts +++ b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts @@ -1,16 +1,16 @@ import { chalk, fs } from "zx"; -import { type Action } from "../../actions"; +import type { Action } from "../../actions/actions"; import { applyPatchForAction, commitActionChanges, regeneratePatchForAction, } from "../../actions/git"; -import type { AppDirPath, PatchesDirPath } from "../../brandedTypes"; import { log } from "../../log"; +import type { AppDirPath, PatchesDirPath } from "../../tutorialApp"; import { waspDbMigrate } from "../../waspCli"; -export async function executeSteps({ +export async function executeActions({ appDir, patchesDir, actions, @@ -20,7 +20,7 @@ export async function executeSteps({ actions: Action[]; }): Promise { for (const action of actions) { - log("info", `${chalk.bold(`[step ${action.id}]`)} ${action.kind}`); + log("info", `${chalk.bold(`[action ${action.id}]`)} ${action.kind}`); await fs.ensureDir(patchesDir); @@ -32,7 +32,7 @@ export async function executeSteps({ } catch (err) { log( "error", - `Failed to apply patch for step ${action.displayName}:\n${err}`, + `Failed to apply patch for action ${action.displayName}:\n${err}`, ); await regeneratePatchForAction({ appDir, action }); await applyPatchForAction({ appDir, action }); @@ -45,7 +45,7 @@ export async function executeSteps({ action satisfies never; } } catch (err) { - log("error", `Error in step with ID ${action.id}:\n\n${err}`); + log("error", `Error in action with ID ${action.id}:\n\n${err}`); process.exit(1); } await commitActionChanges({ appDir, action }); diff --git a/web/tutorial-actions-executor/src/commands/generate-app/index.ts b/web/tutorial-actions-executor/src/commands/generate-app/index.ts new file mode 100644 index 0000000000..752e7b34b0 --- /dev/null +++ b/web/tutorial-actions-executor/src/commands/generate-app/index.ts @@ -0,0 +1,69 @@ +import { Command } from "@commander-js/extra-typings"; +import { $, fs, spinner } from "zx"; + +import type { Action } from "../../actions/actions"; +import { getActionsFromTutorialFiles } from "../../extract-actions"; +import { initGitRepo, mainBranchName } from "../../git"; +import { log } from "../../log"; +import { + docsTutorialDirPath, + docsTutorialPatchesPath, + tutorialAppDirPath, + tutorialAppName, + tutorialAppParentDirPath, + type AppDirPath, + type AppName, + type AppParentDirPath, +} from "../../tutorialApp"; +import { waspNew } from "../../waspCli"; +import { executeActions } from "./execute-actions"; + +export const generateAppCommand = new Command("generate-app") + .description("Generate a new Wasp app based on the tutorial actions") + .action(async () => { + const actions = await getActionsFromTutorialFiles(docsTutorialDirPath); + log("info", `Found ${actions.length} actions in tutorial files.`); + + await generateApp(actions); + }); + +export async function generateApp(actions: Action[]): Promise { + await spinner("Initializing the tutorial app...", () => + initApp({ + tutorialAppDirPath, + tutorialAppParentDirPath, + tutorialAppName, + mainBranchName, + }), + ); + await executeActions({ + appDir: tutorialAppDirPath, + patchesDir: docsTutorialPatchesPath, + actions, + }); + log( + "success", + `Tutorial app has been successfully generated in ${tutorialAppDirPath}`, + ); +} + +async function initApp({ + tutorialAppName, + tutorialAppDirPath, + tutorialAppParentDirPath, + mainBranchName, +}: { + tutorialAppName: AppName; + tutorialAppParentDirPath: AppParentDirPath; + tutorialAppDirPath: AppDirPath; + mainBranchName: string; +}): Promise { + await fs.ensureDir(tutorialAppParentDirPath); + await $`rm -rf ${tutorialAppDirPath}`; + await waspNew({ + appName: tutorialAppName, + appParentDir: tutorialAppParentDirPath, + }); + await initGitRepo(tutorialAppDirPath, mainBranchName); + log("info", `Tutorial app has been initialized in ${tutorialAppDirPath}`); +} diff --git a/web/tutorial-steps-executor/src/commands/list-steps/index.ts b/web/tutorial-actions-executor/src/commands/list-actions/index.ts similarity index 79% rename from web/tutorial-steps-executor/src/commands/list-steps/index.ts rename to web/tutorial-actions-executor/src/commands/list-actions/index.ts index 069a5e6afc..22c908038f 100644 --- a/web/tutorial-steps-executor/src/commands/list-steps/index.ts +++ b/web/tutorial-actions-executor/src/commands/list-actions/index.ts @@ -2,16 +2,16 @@ import { Command } from "@commander-js/extra-typings"; import { basename } from "path"; import { chalk } from "zx"; -import type { Action } from "../../actions"; -import { getActionsFromTutorialFiles } from "../../extractSteps"; -import { tutorialDir } from "../../project"; +import type { Action } from "../../actions/actions"; +import { getActionsFromTutorialFiles } from "../../extract-actions"; +import { docsTutorialDirPath } from "../../tutorialApp"; type ActionsGroupedByFile = Map; -export const listStepsCommand = new Command("list-steps") - .description("List all steps in the tutorial") +export const listActionsCommand = new Command("list-actions") + .description("List all actions in the tutorial") .action(async () => { - const actions = await getActionsFromTutorialFiles(tutorialDir); + const actions = await getActionsFromTutorialFiles(docsTutorialDirPath); const actionsGroupedByFile = groupActionsBySourceFile(actions); displayGroupedActions(actionsGroupedByFile); }); diff --git a/web/tutorial-actions-executor/src/editor.ts b/web/tutorial-actions-executor/src/editor.ts new file mode 100644 index 0000000000..7c96b2b04e --- /dev/null +++ b/web/tutorial-actions-executor/src/editor.ts @@ -0,0 +1,16 @@ +import { confirm } from "@inquirer/prompts"; +import { $ } from "zx"; +import { tutorialAppDirPath } from "./tutorialApp"; + +export async function askToOpenProjectInEditor() { + const editor = process.env.EDITOR; + if (!editor) { + return; + } + const wantsToOpenVSCode = await confirm({ + message: `Do you want to open the app in ${editor}?`, + }); + if (wantsToOpenVSCode) { + await $`$EDITOR ${tutorialAppDirPath}`; + } +} diff --git a/web/tutorial-steps-executor/src/extractSteps/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts similarity index 92% rename from web/tutorial-steps-executor/src/extractSteps/index.ts rename to web/tutorial-actions-executor/src/extract-actions/index.ts index 600ee60854..da3dc343f0 100644 --- a/web/tutorial-steps-executor/src/extractSteps/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -7,17 +7,17 @@ import { mdxJsxFromMarkdown, type MdxJsxFlowElement } from "mdast-util-mdx-jsx"; import { mdxJsx } from "micromark-extension-mdx-jsx"; import { visit } from "unist-util-visit"; +import type { + Action, + ActionCommon, + ActionId, + MarkdownFilePath, +} from "../actions/actions.js"; import { createApplyPatchAction, createMigrateDbAction, - type Action, - type ActionCommon, } from "../actions/index.js"; -import type { - MarkdownFilePath, - StepId, - TutorialDirPath, -} from "../brandedTypes.js"; +import type { TutorialDirPath } from "../tutorialApp.js"; export async function getActionsFromTutorialFiles( tutorialDir: TutorialDirPath, @@ -67,10 +67,10 @@ async function getActionsFromMarkdownFile( if (node.name !== tutorialComponentName) { return; } - const stepId = getAttributeValue(node, "id") as StepId | null; + const actionId = getAttributeValue(node, "id") as ActionId | null; const actionName = getAttributeValue(node, "action"); - if (!stepId) { + if (!actionId) { throw new Error( `TutorialAction component requires the 'id' attribute. File: ${tutorialFilePath}`, ); @@ -84,7 +84,7 @@ async function getActionsFromMarkdownFile( actions.push( createAction(actionName, { - id: stepId, + id: actionId, tutorialFilePath, }), ); diff --git a/web/tutorial-steps-executor/src/files.ts b/web/tutorial-actions-executor/src/files.ts similarity index 100% rename from web/tutorial-steps-executor/src/files.ts rename to web/tutorial-actions-executor/src/files.ts diff --git a/web/tutorial-steps-executor/src/git.ts b/web/tutorial-actions-executor/src/git.ts similarity index 98% rename from web/tutorial-steps-executor/src/git.ts rename to web/tutorial-actions-executor/src/git.ts index 08beced553..bedabb3928 100644 --- a/web/tutorial-steps-executor/src/git.ts +++ b/web/tutorial-actions-executor/src/git.ts @@ -1,5 +1,7 @@ import { $ } from "zx"; +export const mainBranchName = "main"; + export async function generatePatchFromAllChanges( gitRepoDir: string, ): Promise { diff --git a/web/tutorial-steps-executor/src/index.ts b/web/tutorial-actions-executor/src/index.ts similarity index 50% rename from web/tutorial-steps-executor/src/index.ts rename to web/tutorial-actions-executor/src/index.ts index 0aac71cf07..11854845da 100644 --- a/web/tutorial-steps-executor/src/index.ts +++ b/web/tutorial-actions-executor/src/index.ts @@ -1,11 +1,11 @@ import { program } from "@commander-js/extra-typings"; -import { editStepCommand } from "./commands/edit-step"; +import { editActionCommand } from "./commands/edit-action"; import { generateAppCommand } from "./commands/generate-app"; -import { listStepsCommand } from "./commands/list-steps"; +import { listActionsCommand } from "./commands/list-actions"; program .addCommand(generateAppCommand) - .addCommand(editStepCommand) - .addCommand(listStepsCommand) + .addCommand(editActionCommand) + .addCommand(listActionsCommand) .parse(process.argv) .opts(); diff --git a/web/tutorial-steps-executor/src/log.ts b/web/tutorial-actions-executor/src/log.ts similarity index 100% rename from web/tutorial-steps-executor/src/log.ts rename to web/tutorial-actions-executor/src/log.ts diff --git a/web/tutorial-actions-executor/src/tutorialApp.ts b/web/tutorial-actions-executor/src/tutorialApp.ts new file mode 100644 index 0000000000..8f863bc75e --- /dev/null +++ b/web/tutorial-actions-executor/src/tutorialApp.ts @@ -0,0 +1,25 @@ +import path from "path"; + +import type { Branded } from "./brandedTypes"; + +export type AppName = Branded; +export type AppDirPath = Branded; +export type AppParentDirPath = Branded; +export type TutorialDirPath = Branded; +export type PatchesDirPath = Branded; + +export const tutorialAppName = "TodoApp" as AppName; +export const tutorialAppParentDirPath = path.resolve( + "./.generated-apps", +) as AppParentDirPath; +export const tutorialAppDirPath = path.resolve( + tutorialAppParentDirPath, + tutorialAppName, +) as AppDirPath; +export const docsTutorialDirPath = path.resolve( + "../docs/tutorial", +) as TutorialDirPath; +export const docsTutorialPatchesPath = path.resolve( + docsTutorialDirPath, + "patches", +) as PatchesDirPath; diff --git a/web/tutorial-steps-executor/src/waspCli.ts b/web/tutorial-actions-executor/src/waspCli.ts similarity index 100% rename from web/tutorial-steps-executor/src/waspCli.ts rename to web/tutorial-actions-executor/src/waspCli.ts diff --git a/web/tutorial-steps-executor/tsconfig.json b/web/tutorial-actions-executor/tsconfig.json similarity index 100% rename from web/tutorial-steps-executor/tsconfig.json rename to web/tutorial-actions-executor/tsconfig.json diff --git a/web/tutorial-steps-executor/.gitignore b/web/tutorial-steps-executor/.gitignore deleted file mode 100644 index 72335df6a5..0000000000 --- a/web/tutorial-steps-executor/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -TodoApp/ diff --git a/web/tutorial-steps-executor/README.md b/web/tutorial-steps-executor/README.md deleted file mode 100644 index 62ae59a2ef..0000000000 --- a/web/tutorial-steps-executor/README.md +++ /dev/null @@ -1,158 +0,0 @@ -# Tutorial Steps Executor - -A CLI tool for managing and editing tutorial steps in Wasp docs. This tool allows you to execute all tutorial steps to get the final app or edit individual steps. - -## Comamnds - -The CLI provides three main commands: - -### 1. Generate App (`npm run generate-app`) - -Creates a complete Wasp application by executing all tutorial steps in sequence. - -```bash -npm run generate-app -``` - -This command: - -- Initializes a new Wasp application -- Reads all tutorial markdown files (numbered like `01-setup.md`, `02-auth.md`, etc.) -- Extracts `` components from the markdown -- Applies each step's patches or database migrations in order -- Creates a Git commit for each step -- Results in a fully functional application with complete Git history - -### 2. Edit Step (`npm run edit-step`) - -Allows you to modify a specific tutorial step and automatically update all subsequent steps. - -```bash -npm run edit-step --step-id "create-task-entity" -# or interactive mode: -npm run edit-step -``` - -This command: - -- Generates the app (unless `--skip-generating-app` is used) -- Creates a branch from the step's commit -- Captures your changes as a new patch -- Rebases the changes through all subsequent steps -- Updates all affected patch files - -### 3. List Steps (`npm run list-steps`) - -Displays all available tutorial steps organized by source file. - -```bash -npm run list-steps -``` - -## How It Works on Git Level, Patches, and Rebasing - -The tool uses a Git-based workflow to manage tutorial steps: - -### Executing Tutorial Steps - -1. **Initial Setup**: Creates a Git repository with an initial commit -2. **Step Execution**: Each step is executed and commited as a separate Git commit - with the step ID as the commit message - -### Step Editing Process - -When editing a tutorial step (e.g., step 4 out of 10 total steps): - -#### Phase 1: Setup and Branching - -```bash -# Generate app with all 10 steps, each as a commit -git init -git commit -m "Initial commit" -git commit -m "step-1-setup" -git commit -m "step-2-auth" -git commit -m "step-3-database" -git commit -m "step-4-create-task-entity" # ← Target step -# ... and so on - -# Create branch from step 4's commit -git switch --force-create fixes -``` - -#### Phase 2: User Editing - -```bash -# Move the step 4 commit changes to staging area -git reset --soft HEAD~1 - -# User makes their edits in the editor -# User confirms they're done -``` - -#### Phase 3: Patch Creation and Application - -```bash -# Commit all current changes and generate a new patch -git add . -git commit -m "temporary-commit" -git show HEAD --format= > new-patch.patch -git reset --hard HEAD~1 - -# Apply the new patch and commit with original step ID -git apply new-patch.patch -git commit -m "step-4-create-task-entity" -``` - -#### Phase 4: Rebasing and Integration - -```bash -# Switch back to main branch and rebase the fixes -git switch main -git rebase fixes # This integrates the fixed step 4 - -# If conflicts occur, user resolves them like any other Git conflict -``` - -#### Phase 5: Patch File Updates - -```bash -# Regenerate all patch files from the updated commits -# For each step after the edited one: -git show step-commit-sha --format= > patches/step-N.patch -``` - -### Patch File Management - -- Patch files are stored in the `./docs/tutorial/patches/` directory -- Files are named after their step IDs (e.g., `create-task-entity.patch`) -- Contains the Git diff for that specific step -- When a step is edited, all subsequent patch files are automatically regenerated - -### Database Migration Handling - -For steps that involve database changes: - -- Uses `wasp db migrate` command instead of patch application -- Migration files are committed with the step ID as the commit message - -### Tutorial File Format - -Tutorial steps are defined in markdown files using JSX-like components: - -```markdown -# Step 4: Create Task Entity - - - -In this step, we'll create a Task entity... -``` - -The tool extracts these components and uses: - -- `id`: Unique identifier for the step (becomes commit message) -- `action`: Type of action (`apply-patch` or `migrate-db`) - -This Git-based approach ensures that: - -- **Changes can be made to any step** without breaking subsequent steps -- **Conflicts are automatically handled** by Git's rebasing mechanism diff --git a/web/tutorial-steps-executor/src/brandedTypes.ts b/web/tutorial-steps-executor/src/brandedTypes.ts deleted file mode 100644 index 9974187de3..0000000000 --- a/web/tutorial-steps-executor/src/brandedTypes.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type AppName = Branded; -export type AppDirPath = Branded; -export type AppParentDirPath = Branded; -export type PatchesDirPath = Branded; -export type TutorialDirPath = Branded; -export type PatchFilePath = Branded; -export type StepId = Branded; -export type MarkdownFilePath = Branded; - -declare const __brand: unique symbol; -type Brand = { [__brand]: B }; -type Branded = T & Brand; diff --git a/web/tutorial-steps-executor/src/commands/generate-app/index.ts b/web/tutorial-steps-executor/src/commands/generate-app/index.ts deleted file mode 100644 index 798c021efe..0000000000 --- a/web/tutorial-steps-executor/src/commands/generate-app/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Command } from "@commander-js/extra-typings"; -import { $, spinner } from "zx"; - -import type { Action } from "../../actions"; -import type { AppDirPath, AppName, AppParentDirPath } from "../../brandedTypes"; -import { getActionsFromTutorialFiles } from "../../extractSteps"; -import { initGitRepo } from "../../git"; -import { log } from "../../log"; -import { - appDir, - appName, - appParentDir, - mainBranchName, - patchesDir, - tutorialDir, -} from "../../project"; -import { waspNew } from "../../waspCli"; -import { executeSteps } from "./execute-steps"; - -export const generateAppCommand = new Command("generate-app") - .description("Generate a new Wasp app based on the tutorial steps") - .action(async () => { - const actions = await getActionsFromTutorialFiles(tutorialDir); - log("info", `Found ${actions.length} actions in tutorial files.`); - - await generateApp(actions); - }); - -export async function generateApp(actions: Action[]): Promise { - await spinner("Initializing the tutorial app...", () => - initApp({ appDir, appParentDir, appName, mainBranchName }), - ); - await executeSteps({ appDir, patchesDir, actions }); - log("success", `Tutorial app has been successfully generated in ${appDir}`); -} - -async function initApp({ - appName, - appParentDir, - appDir, - mainBranchName, -}: { - appName: AppName; - appParentDir: AppParentDirPath; - appDir: AppDirPath; - mainBranchName: string; -}): Promise { - await $`rm -rf ${appDir}`; - await waspNew({ appName, appParentDir }); - await initGitRepo(appDir, mainBranchName); - log("info", `Tutorial app has been initialized in ${appDir}`); -} diff --git a/web/tutorial-steps-executor/src/editor.ts b/web/tutorial-steps-executor/src/editor.ts deleted file mode 100644 index 7f680d3b76..0000000000 --- a/web/tutorial-steps-executor/src/editor.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { confirm } from "@inquirer/prompts"; -import { $ } from "zx"; - -import { appDir } from "./project"; - -export async function askToOpenProjectInEditor() { - const wantsToOpenVSCode = await confirm({ - message: `Do you want to open the app in VS Code?`, - }); - if (wantsToOpenVSCode) { - await $`code ${appDir}`; - } -} diff --git a/web/tutorial-steps-executor/src/project.ts b/web/tutorial-steps-executor/src/project.ts deleted file mode 100644 index fcd31efa7b..0000000000 --- a/web/tutorial-steps-executor/src/project.ts +++ /dev/null @@ -1,19 +0,0 @@ -import path from "path"; - -import type { - AppDirPath, - AppName, - AppParentDirPath, - PatchesDirPath, - TutorialDirPath, -} from "./brandedTypes"; - -export const appName = "TodoApp" as AppName; -export const appParentDir = path.resolve("./") as AppParentDirPath; -export const appDir = path.resolve(appParentDir, appName) as AppDirPath; -export const tutorialDir = path.resolve("../docs/tutorial") as TutorialDirPath; -export const patchesDir = path.resolve( - tutorialDir, - "patches", -) as PatchesDirPath; -export const mainBranchName = "main"; From 3af974a1485500f6eacf1f98c3f8b6a18561fc07 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 12 Sep 2025 14:43:11 +0200 Subject: [PATCH 30/48] Update naming Signed-off-by: Mihovil Ilakovac --- web/docs/tutorial/03-pages.md | 2 +- web/docs/tutorial/04-entities.md | 4 +-- web/docs/tutorial/05-queries.md | 6 ++--- web/docs/tutorial/06-actions.md | 14 +++++----- web/docs/tutorial/07-auth.md | 26 +++++++++---------- web/docs/tutorial/TutorialAction.tsx | 24 ++++++++++------- web/tutorial-actions-executor/README.md | 4 +-- .../src/actions/actions.ts | 4 +-- .../src/actions/index.ts | 4 +-- .../src/commands/edit-action/index.ts | 4 +-- .../commands/generate-app/execute-actions.ts | 4 +-- .../src/commands/list-actions/index.ts | 4 +-- .../src/extract-actions/index.ts | 4 +-- 13 files changed, 54 insertions(+), 50 deletions(-) diff --git a/web/docs/tutorial/03-pages.md b/web/docs/tutorial/03-pages.md index 404e554dd2..87151073fe 100644 --- a/web/docs/tutorial/03-pages.md +++ b/web/docs/tutorial/03-pages.md @@ -97,7 +97,7 @@ Now that you've seen how Wasp deals with Routes and Pages, it's finally time to Start by cleaning up the starter project and removing unnecessary code and files. - + First, remove most of the code from the `MainPage` component: diff --git a/web/docs/tutorial/04-entities.md b/web/docs/tutorial/04-entities.md index c7fff59a8a..f7b163f13f 100644 --- a/web/docs/tutorial/04-entities.md +++ b/web/docs/tutorial/04-entities.md @@ -11,7 +11,7 @@ Wasp uses Prisma to talk to the database, and you define Entities by defining Pr Since our Todo app is all about tasks, we'll define a Task entity by adding a Task model in the `schema.prisma` file: - + ```prisma title="schema.prisma" // ... @@ -29,7 +29,7 @@ Read more about how Wasp Entities work in the [Entities](../data-model/entities. To update the database schema to include this entity, stop the `wasp start` process, if it's running, and run: - + ```sh wasp db migrate-dev ``` diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index 33aa331277..2901cddee8 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -44,7 +44,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis - + ```wasp title="main.wasp" // ... @@ -76,7 +76,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis Next, create a new file called `src/queries.ts` and define the TypeScript function we've just imported in our `query` declaration: - + ```ts title="src/queries.ts" auto-js import type { Task } from "wasp/entities"; @@ -122,7 +122,7 @@ While we implement Queries on the server, Wasp generates client-side functions t This makes it easy for us to use the `getTasks` Query we just created in our React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { Task } from "wasp/entities"; diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index 105b359bfb..837d66cd90 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -23,7 +23,7 @@ Creating an Action is very similar to creating a Query. We must first declare the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -38,7 +38,7 @@ action createTask { Let's now define a JavaScriptTypeScript function for our `createTask` Action: - + ```ts title="src/actions.ts" auto-js import type { Task } from "wasp/entities"; @@ -68,7 +68,7 @@ We put the function in a new file `src/actions.{js,ts}`, but we could have put i Start by defining a form for creating new tasks. - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from "react"; @@ -114,7 +114,7 @@ Unlike Queries, you can call Actions directly (without wrapping them in a hook) All that's left now is adding this form to the page component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from "react"; @@ -171,7 +171,7 @@ Since we've already created one task together, try to create this one yourself. Declaring the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -182,7 +182,7 @@ action updateTask { } ``` - + Implementing the Action on the server: @@ -210,7 +210,7 @@ export const updateTask: UpdateTask = async ( You can now call `updateTask` from the React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent, ChangeEvent } from "react"; diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 4fa63fa825..98f141da81 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -24,7 +24,7 @@ Since Wasp manages authentication, it will create [the auth related entities](.. You must only add the `User` Entity to keep track of who owns which tasks: - + ```prisma title="schema.prisma" // ... @@ -38,7 +38,7 @@ model User { Next, tell Wasp to use full-stack [authentication](../auth/overview): - + ```wasp title="main.wasp" app TodoApp { @@ -68,7 +68,7 @@ app TodoApp { Don't forget to update the database schema by running: - + ```sh wasp db migrate-dev @@ -89,7 +89,7 @@ Wasp also supports authentication using [Google](../auth/social-auth/google), [G Wasp creates the login and signup forms for us, but we still need to define the pages to display those forms on. We'll start by declaring the pages in the Wasp file: - + ```wasp title="main.wasp" // ... @@ -109,7 +109,7 @@ Great, Wasp now knows these pages exist! Here's the React code for the pages you've just imported: - + ```tsx title="src/LoginPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -130,7 +130,7 @@ export const LoginPage = () => { The signup page is very similar to the login page: - + ```tsx title="src/SignupPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -159,7 +159,7 @@ export const SignupPage = () => { We don't want users who are not logged in to access the main page, because they won't be able to create any tasks. So let's make the page private by requiring the user to be logged in: - + ```wasp title="main.wasp" // ... @@ -175,7 +175,7 @@ Now that auth is required for this page, unauthenticated users will be redirecte Additionally, when `authRequired` is `true`, the page's React component will be provided a `user` object as prop. - + ```tsx title="src/MainPage.tsx" auto-js import type { AuthUser } from "wasp/auth"; @@ -211,7 +211,7 @@ However, you will notice that if you try logging in as different users and creat First, let's define a one-to-many relation between users and tasks (check the [Prisma docs on relations](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/relations)): - + ```prisma title="schema.prisma" // ... @@ -235,7 +235,7 @@ model Task { As always, you must migrate the database after changing the Entities: - + ```sh wasp db migrate-dev ``` @@ -252,7 +252,7 @@ Instead, we would do a data migration to take care of those tasks, even if it me Next, let's update the queries and actions to forbid access to non-authenticated users and to operate only on the currently logged-in user's tasks: - + ```ts title="src/queries.ts" auto-js import type { Task } from "wasp/entities"; @@ -274,7 +274,7 @@ export const getTasks: GetTasks = async (args, context) => { }; ``` - + ```ts title="src/actions.ts" auto-js import type { Task } from "wasp/entities"; @@ -340,7 +340,7 @@ You will see that each user has their tasks, just as we specified in our code! Last, but not least, let's add the logout functionality: - + ```tsx title="src/MainPage.tsx" auto-js with-hole // ... diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index 8e48a8b450..25e89118e3 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -1,26 +1,30 @@ -// If you change it here, make sure to also update the types in `tutorial-app-generator/src/executeSteps/actions.ts`. -type Action = "apply-patch" | "migrate-db"; - /* -This component serves two purposes: -1. It provides metadata for the `tutorial-app-generator` on how to execute tutorial steps programmatically. -2. It renders tutorial step names during development for easier debugging. +`TutorialAction` component is related to the Tutorial Actions Executor which you can find in the `web/tutorial-actions-executor` folder. +It has two main purposes: +1. It provides metadata on how to execute tutorial actions programmatically. +2. It renders tutorial action names during development for easier debugging. + +`TutorialAction` component is used in the `web/docs/tutorial/*.md` files to annotate specific tutorial actions. */ + +// IMPORTANT: If you change it here, make sure to also update the types in `web/tutorial-actions-executor/src/actions/actions.ts`. +type Action = "APPLY_PATCH" | "MIGRATE_DB"; + export function TutorialAction({ action, id }: { action: Action; id: string }) { return ( process.env.NODE_ENV !== "production" && (
- +
) ); } -function TutorialActionStep({ id, action }: { id: string; action: Action }) { +function TutorialActionDebug({ id, action }: { id: string; action: Action }) { return (
tutorial action: {action}
-
+
{id} + In this action, we'll create the Task entity: ```prisma @@ -83,7 +83,7 @@ model Task { The tool extracts these components and uses: - `id`: Unique identifier for the action (becomes commit message) -- `action`: Type of action (`apply-patch` or `migrate-db`) +- `action`: Type of action (`APPLY_PATCH` or `MIGRATE_DB`) This Git-based approach ensures that: diff --git a/web/tutorial-actions-executor/src/actions/actions.ts b/web/tutorial-actions-executor/src/actions/actions.ts index 2d70ca3864..a8bcf06c60 100644 --- a/web/tutorial-actions-executor/src/actions/actions.ts +++ b/web/tutorial-actions-executor/src/actions/actions.ts @@ -4,13 +4,13 @@ import type { Branded } from "../brandedTypes"; export type Action = ApplyPatchAction | MigrateDbAction; export type ApplyPatchAction = { - kind: "apply-patch"; + kind: "APPLY_PATCH"; displayName: string; patchFilePath: PatchFilePath; } & ActionCommon; export type MigrateDbAction = { - kind: "migrate-db"; + kind: "MIGRATE_DB"; } & ActionCommon; export type ActionCommon = { diff --git a/web/tutorial-actions-executor/src/actions/index.ts b/web/tutorial-actions-executor/src/actions/index.ts index 6733166519..d6a0e741cf 100644 --- a/web/tutorial-actions-executor/src/actions/index.ts +++ b/web/tutorial-actions-executor/src/actions/index.ts @@ -13,7 +13,7 @@ export function createMigrateDbAction( ): MigrateDbAction { return { ...commonData, - kind: "migrate-db", + kind: "MIGRATE_DB", }; } @@ -23,7 +23,7 @@ export function createApplyPatchAction( const patchFilePath = getPatchFilePath(commonData); return { ...commonData, - kind: "apply-patch", + kind: "APPLY_PATCH", displayName: `${basename(commonData.tutorialFilePath)} / ${commonData.id}`, patchFilePath, }; diff --git a/web/tutorial-actions-executor/src/commands/edit-action/index.ts b/web/tutorial-actions-executor/src/commands/edit-action/index.ts index 61edda1d6c..fdcdece3ad 100644 --- a/web/tutorial-actions-executor/src/commands/edit-action/index.ts +++ b/web/tutorial-actions-executor/src/commands/edit-action/index.ts @@ -105,7 +105,7 @@ async function editActionPatch({ async function extractCommitsIntoPatches(actions: Action[]): Promise { const applyPatchActions = actions.filter( - (action) => action.kind === "apply-patch", + (action) => action.kind === "APPLY_PATCH", ); for (const action of applyPatchActions) { @@ -126,7 +126,7 @@ async function ensureAction({ actionIdOptionValue: string | undefined; }): Promise { const applyPatchActions = actions.filter( - (action) => action.kind === "apply-patch", + (action) => action.kind === "APPLY_PATCH", ); if (!actionIdOptionValue) { diff --git a/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts index ac888d12aa..4b1bf519e1 100644 --- a/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts +++ b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts @@ -26,7 +26,7 @@ export async function executeActions({ try { switch (action.kind) { - case "apply-patch": + case "APPLY_PATCH": try { await applyPatchForAction({ appDir, action }); } catch (err) { @@ -38,7 +38,7 @@ export async function executeActions({ await applyPatchForAction({ appDir, action }); } break; - case "migrate-db": + case "MIGRATE_DB": await waspDbMigrate(appDir, action.id); break; default: diff --git a/web/tutorial-actions-executor/src/commands/list-actions/index.ts b/web/tutorial-actions-executor/src/commands/list-actions/index.ts index 22c908038f..c3c2714ade 100644 --- a/web/tutorial-actions-executor/src/commands/list-actions/index.ts +++ b/web/tutorial-actions-executor/src/commands/list-actions/index.ts @@ -46,8 +46,8 @@ function displayFileHeader(filename: string): void { function displayActionsForFile(actions: Action[]): void { const kindColorMap: Record string> = { - "apply-patch": chalk.green, - "migrate-db": chalk.blue, + APPLY_PATCH: chalk.green, + MIGRATE_DB: chalk.blue, }; actions.forEach((action) => { diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index da3dc343f0..18c4ff9c41 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -95,8 +95,8 @@ async function getActionsFromMarkdownFile( function createAction(actionName: string, commonData: ActionCommon): Action { const actionCreators: Record Action> = { - "apply-patch": createApplyPatchAction, - "migrate-db": createMigrateDbAction, + APPLY_PATCH: createApplyPatchAction, + MIGRATE_DB: createMigrateDbAction, }; const createFn = actionCreators[actionName]; if (!createFn) { From 04f9adec742214e76479a3e12a81ebcd1701160f Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 12 Sep 2025 15:57:50 +0200 Subject: [PATCH 31/48] Move app init into a separate action Signed-off-by: Mihovil Ilakovac --- web/docs/tutorial/01-create.md | 3 ++ web/docs/tutorial/TutorialAction.tsx | 24 +++++++-- web/tutorial-actions-executor/README.md | 2 +- .../src/actions/actions.ts | 9 +++- .../src/actions/git.ts | 4 +- .../src/actions/index.ts | 12 +++++ .../src/actions/init.ts | 31 +++++++++++ .../commands/generate-app/execute-actions.ts | 25 +++++++-- .../src/commands/generate-app/index.ts | 37 ------------- .../src/commands/list-actions/index.ts | 1 + .../src/extract-actions/index.ts | 52 ++++++++++++------- web/tutorial-actions-executor/src/waspCli.ts | 2 +- 12 files changed, 133 insertions(+), 69 deletions(-) create mode 100644 web/tutorial-actions-executor/src/actions/init.ts diff --git a/web/docs/tutorial/01-create.md b/web/docs/tutorial/01-create.md index c4a9bd213e..a2db0770d4 100644 --- a/web/docs/tutorial/01-create.md +++ b/web/docs/tutorial/01-create.md @@ -3,6 +3,7 @@ title: 1. Creating a New Project --- import useBaseUrl from '@docusaurus/useBaseUrl'; +import { TutorialAction } from './TutorialAction'; :::info You'll need to have the latest version of Wasp installed locally to follow this tutorial. If you haven't installed it yet, check out the [QuickStart](../quick-start) guide! @@ -24,6 +25,8 @@ You can find the complete code of the app we're about to build [here](https://gi To setup a new Wasp project, run the following command in your terminal: + + ```sh wasp new TodoApp -t minimal ``` diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index 25e89118e3..b8c4e1dfe2 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -7,10 +7,20 @@ It has two main purposes: `TutorialAction` component is used in the `web/docs/tutorial/*.md` files to annotate specific tutorial actions. */ -// IMPORTANT: If you change it here, make sure to also update the types in `web/tutorial-actions-executor/src/actions/actions.ts`. -type Action = "APPLY_PATCH" | "MIGRATE_DB"; +// IMPORTANT: If you change actions here, make sure to also update the types in `web/tutorial-actions-executor/src/actions/actions.ts`. +type ActionProps = + | { + action: "INIT_APP"; + starterTemplateName: string; + } + | { + action: "APPLY_PATCH"; + } + | { + action: "MIGRATE_DB"; + }; -export function TutorialAction({ action, id }: { action: Action; id: string }) { +export function TutorialAction({ id, action }: { id: string } & ActionProps) { return ( process.env.NODE_ENV !== "production" && (
@@ -20,7 +30,13 @@ export function TutorialAction({ action, id }: { action: Action; id: string }) { ); } -function TutorialActionDebug({ id, action }: { id: string; action: Action }) { +function TutorialActionDebug({ + id, + action, +}: { + id: string; + action: ActionProps["action"]; +}) { return (
tutorial action: {action}
diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index c3354a81f8..5573ed01f2 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -83,7 +83,7 @@ model Task { The tool extracts these components and uses: - `id`: Unique identifier for the action (becomes commit message) -- `action`: Type of action (`APPLY_PATCH` or `MIGRATE_DB`) +- `action`: Type of action (`INIT_APP`, `APPLY_PATCH`, `MIGRATE_DB`) This Git-based approach ensures that: diff --git a/web/tutorial-actions-executor/src/actions/actions.ts b/web/tutorial-actions-executor/src/actions/actions.ts index a8bcf06c60..40b972b4de 100644 --- a/web/tutorial-actions-executor/src/actions/actions.ts +++ b/web/tutorial-actions-executor/src/actions/actions.ts @@ -1,7 +1,12 @@ import type { Branded } from "../brandedTypes"; -// If modify or add new action kinds, make sure to also update the type in `docs/tutorial/TutorialAction.tsx`. -export type Action = ApplyPatchAction | MigrateDbAction; +// If modify or add new action kinds, make sure to also update the type in `web/docs/tutorial/TutorialAction.tsx`. +export type Action = InitAppAction | ApplyPatchAction | MigrateDbAction; + +export type InitAppAction = { + kind: "INIT_APP"; + waspStarterTemplateName: string; +} & ActionCommon; export type ApplyPatchAction = { kind: "APPLY_PATCH"; diff --git a/web/tutorial-actions-executor/src/actions/git.ts b/web/tutorial-actions-executor/src/actions/git.ts index 8ac7c94030..37f15894c4 100644 --- a/web/tutorial-actions-executor/src/actions/git.ts +++ b/web/tutorial-actions-executor/src/actions/git.ts @@ -2,8 +2,6 @@ import { confirm } from "@inquirer/prompts"; import parseGitDiff from "parse-git-diff"; import { fs } from "zx"; -import type { Action, ApplyPatchAction } from "."; -import type { AppDirPath } from "../brandedTypes"; import { askToOpenProjectInEditor } from "../editor"; import { applyPatch, @@ -14,6 +12,8 @@ import { generatePatchFromRevision, } from "../git"; import { log } from "../log"; +import type { AppDirPath } from "../tutorialApp"; +import type { Action, ApplyPatchAction } from "./actions"; export function commitActionChanges({ appDir, diff --git a/web/tutorial-actions-executor/src/actions/index.ts b/web/tutorial-actions-executor/src/actions/index.ts index d6a0e741cf..5202c930d2 100644 --- a/web/tutorial-actions-executor/src/actions/index.ts +++ b/web/tutorial-actions-executor/src/actions/index.ts @@ -4,10 +4,22 @@ import { docsTutorialPatchesPath } from "../tutorialApp"; import type { ActionCommon, ApplyPatchAction, + InitAppAction, MigrateDbAction, PatchFilePath, } from "./actions"; +export function createInitAppAction( + commonData: ActionCommon, + waspStarterTemplateName: string, +): InitAppAction { + return { + ...commonData, + kind: "INIT_APP", + waspStarterTemplateName, + }; +} + export function createMigrateDbAction( commonData: ActionCommon, ): MigrateDbAction { diff --git a/web/tutorial-actions-executor/src/actions/init.ts b/web/tutorial-actions-executor/src/actions/init.ts new file mode 100644 index 0000000000..50672c4f00 --- /dev/null +++ b/web/tutorial-actions-executor/src/actions/init.ts @@ -0,0 +1,31 @@ +import { $, fs } from "zx"; + +import { initGitRepo } from "../git"; +import { log } from "../log"; +import { + type AppDirPath, + type AppName, + type AppParentDirPath, +} from "../tutorialApp"; +import { waspNew } from "../waspCli"; + +export async function initApp({ + tutorialAppName, + tutorialAppDirPath, + tutorialAppParentDirPath, + mainBranchName, +}: { + tutorialAppName: AppName; + tutorialAppParentDirPath: AppParentDirPath; + tutorialAppDirPath: AppDirPath; + mainBranchName: string; +}): Promise { + await fs.ensureDir(tutorialAppParentDirPath); + await $`rm -rf ${tutorialAppDirPath}`; + await waspNew({ + appName: tutorialAppName, + appParentDir: tutorialAppParentDirPath, + }); + await initGitRepo(tutorialAppDirPath, mainBranchName); + log("info", `Tutorial app has been initialized in ${tutorialAppDirPath}`); +} diff --git a/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts index 4b1bf519e1..9fdd21d04b 100644 --- a/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts +++ b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts @@ -1,4 +1,4 @@ -import { chalk, fs } from "zx"; +import { chalk, fs, spinner } from "zx"; import type { Action } from "../../actions/actions"; import { @@ -6,8 +6,16 @@ import { commitActionChanges, regeneratePatchForAction, } from "../../actions/git"; +import { initApp } from "../../actions/init"; +import { mainBranchName } from "../../git"; import { log } from "../../log"; -import type { AppDirPath, PatchesDirPath } from "../../tutorialApp"; +import { + tutorialAppDirPath, + tutorialAppName, + tutorialAppParentDirPath, + type AppDirPath, + type PatchesDirPath, +} from "../../tutorialApp"; import { waspDbMigrate } from "../../waspCli"; export async function executeActions({ @@ -26,6 +34,16 @@ export async function executeActions({ try { switch (action.kind) { + case "INIT_APP": + await spinner("Initializing the tutorial app...", () => + initApp({ + tutorialAppDirPath, + tutorialAppParentDirPath, + tutorialAppName, + mainBranchName, + }), + ); + break; case "APPLY_PATCH": try { await applyPatchForAction({ appDir, action }); @@ -37,9 +55,11 @@ export async function executeActions({ await regeneratePatchForAction({ appDir, action }); await applyPatchForAction({ appDir, action }); } + await commitActionChanges({ appDir, action }); break; case "MIGRATE_DB": await waspDbMigrate(appDir, action.id); + await commitActionChanges({ appDir, action }); break; default: action satisfies never; @@ -48,6 +68,5 @@ export async function executeActions({ log("error", `Error in action with ID ${action.id}:\n\n${err}`); process.exit(1); } - await commitActionChanges({ appDir, action }); } } diff --git a/web/tutorial-actions-executor/src/commands/generate-app/index.ts b/web/tutorial-actions-executor/src/commands/generate-app/index.ts index 752e7b34b0..ebb2e2bd94 100644 --- a/web/tutorial-actions-executor/src/commands/generate-app/index.ts +++ b/web/tutorial-actions-executor/src/commands/generate-app/index.ts @@ -1,21 +1,13 @@ import { Command } from "@commander-js/extra-typings"; -import { $, fs, spinner } from "zx"; import type { Action } from "../../actions/actions"; import { getActionsFromTutorialFiles } from "../../extract-actions"; -import { initGitRepo, mainBranchName } from "../../git"; import { log } from "../../log"; import { docsTutorialDirPath, docsTutorialPatchesPath, tutorialAppDirPath, - tutorialAppName, - tutorialAppParentDirPath, - type AppDirPath, - type AppName, - type AppParentDirPath, } from "../../tutorialApp"; -import { waspNew } from "../../waspCli"; import { executeActions } from "./execute-actions"; export const generateAppCommand = new Command("generate-app") @@ -28,14 +20,6 @@ export const generateAppCommand = new Command("generate-app") }); export async function generateApp(actions: Action[]): Promise { - await spinner("Initializing the tutorial app...", () => - initApp({ - tutorialAppDirPath, - tutorialAppParentDirPath, - tutorialAppName, - mainBranchName, - }), - ); await executeActions({ appDir: tutorialAppDirPath, patchesDir: docsTutorialPatchesPath, @@ -46,24 +30,3 @@ export async function generateApp(actions: Action[]): Promise { `Tutorial app has been successfully generated in ${tutorialAppDirPath}`, ); } - -async function initApp({ - tutorialAppName, - tutorialAppDirPath, - tutorialAppParentDirPath, - mainBranchName, -}: { - tutorialAppName: AppName; - tutorialAppParentDirPath: AppParentDirPath; - tutorialAppDirPath: AppDirPath; - mainBranchName: string; -}): Promise { - await fs.ensureDir(tutorialAppParentDirPath); - await $`rm -rf ${tutorialAppDirPath}`; - await waspNew({ - appName: tutorialAppName, - appParentDir: tutorialAppParentDirPath, - }); - await initGitRepo(tutorialAppDirPath, mainBranchName); - log("info", `Tutorial app has been initialized in ${tutorialAppDirPath}`); -} diff --git a/web/tutorial-actions-executor/src/commands/list-actions/index.ts b/web/tutorial-actions-executor/src/commands/list-actions/index.ts index c3c2714ade..8a2c3d4213 100644 --- a/web/tutorial-actions-executor/src/commands/list-actions/index.ts +++ b/web/tutorial-actions-executor/src/commands/list-actions/index.ts @@ -46,6 +46,7 @@ function displayFileHeader(filename: string): void { function displayActionsForFile(actions: Action[]): void { const kindColorMap: Record string> = { + INIT_APP: chalk.yellow, APPLY_PATCH: chalk.green, MIGRATE_DB: chalk.blue, }; diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 18c4ff9c41..5f15ee5c30 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -15,6 +15,7 @@ import type { } from "../actions/actions.js"; import { createApplyPatchAction, + createInitAppAction, createMigrateDbAction, } from "../actions/index.js"; import type { TutorialDirPath } from "../tutorialApp.js"; @@ -68,7 +69,13 @@ async function getActionsFromMarkdownFile( return; } const actionId = getAttributeValue(node, "id") as ActionId | null; - const actionName = getAttributeValue(node, "action"); + const actionName = getAttributeValue(node, "action") as + | Action["kind"] + | null; + const waspStarterTemplateName = getAttributeValue( + node, + "starterTemplateName", + ); if (!actionId) { throw new Error( @@ -82,29 +89,36 @@ async function getActionsFromMarkdownFile( ); } - actions.push( - createAction(actionName, { - id: actionId, - tutorialFilePath, - }), - ); + const commonData: ActionCommon = { + id: actionId, + tutorialFilePath, + }; + switch (actionName) { + case "INIT_APP": + if (waspStarterTemplateName === null) { + throw new Error( + `TutorialAction with action 'INIT_APP' requires the 'starterTemplateName' attribute. File: ${tutorialFilePath}`, + ); + } + actions.push(createInitAppAction(commonData, waspStarterTemplateName)); + break; + case "APPLY_PATCH": + actions.push(createApplyPatchAction(commonData)); + break; + case "MIGRATE_DB": + actions.push(createMigrateDbAction(commonData)); + break; + default: + actionName satisfies never; + throw new Error( + `Unknown action '${actionName}' in TutorialAction component. File: ${tutorialFilePath}`, + ); + } }); return actions; } -function createAction(actionName: string, commonData: ActionCommon): Action { - const actionCreators: Record Action> = { - APPLY_PATCH: createApplyPatchAction, - MIGRATE_DB: createMigrateDbAction, - }; - const createFn = actionCreators[actionName]; - if (!createFn) { - throw new Error(`Unknown action type: ${actionName}`); - } - return createFn(commonData); -} - function getAttributeValue( node: MdxJsxFlowElement, attributeName: string, diff --git a/web/tutorial-actions-executor/src/waspCli.ts b/web/tutorial-actions-executor/src/waspCli.ts index dab1d7e967..98f6886fda 100644 --- a/web/tutorial-actions-executor/src/waspCli.ts +++ b/web/tutorial-actions-executor/src/waspCli.ts @@ -1,5 +1,5 @@ import { $ } from "zx"; -import type { AppDirPath, AppName, AppParentDirPath } from "./brandedTypes"; +import type { AppDirPath, AppName, AppParentDirPath } from "./tutorialApp"; export async function waspDbMigrate( appDir: AppDirPath, From b3f9aae57dfb106da151bbcee4faf856d8b8edd0 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 12 Sep 2025 17:44:23 +0200 Subject: [PATCH 32/48] PR comments Signed-off-by: Mihovil Ilakovac --- web/tutorial-actions-executor/README.md | 39 +++++--- .../src/actions/actions.ts | 16 ++-- .../src/actions/git.ts | 6 +- .../src/actions/index.ts | 11 ++- .../src/actions/init.ts | 34 ++++--- .../src/commands/commonOptions.ts | 6 ++ .../src/commands/edit-action/index.ts | 23 +++-- .../commands/generate-app/execute-actions.ts | 92 +++++++++---------- .../src/commands/generate-app/index.ts | 27 +++--- .../src/commands/list-actions/index.ts | 11 +-- web/tutorial-actions-executor/src/editor.ts | 10 +- .../src/extract-actions/index.ts | 18 +++- web/tutorial-actions-executor/src/git.ts | 64 ++++++------- .../src/tutorialApp.ts | 22 +++-- web/tutorial-actions-executor/src/waspCli.ts | 28 ++++-- 15 files changed, 231 insertions(+), 176 deletions(-) create mode 100644 web/tutorial-actions-executor/src/commands/commonOptions.ts diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index 5573ed01f2..ce6cea3a74 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -2,7 +2,7 @@ A CLI tool for managing and editing tutorial actions in Wasp docs. This tool allows you to execute all tutorial actions to get the final app or edit individual actions. -## Comamnds +## Commands The CLI provides three main commands: @@ -12,6 +12,8 @@ Creates a complete Wasp application by executing all tutorial actions in sequenc ```bash npm run generate-app +# Optional: pass a custom Wasp CLI binary/command +npm run generate-app -- --wasp-cli-command wasp ``` This command: @@ -21,24 +23,37 @@ This command: - Extracts `` components from the markdown - Applies each action's patches or database migrations in order - Creates a Git commit for each action -- Results in a fully functional application. +- Results in a fully functional application + +Notes: + +- Stops on the first error to keep the repository in a consistent state. ### 2. Edit Action (`npm run edit-action`) Allows you to modify a specific tutorial action and automatically update all subsequent actions. ```bash -npm run edit-action --action-id "create-task-entity" -# or interactive mode: +# Non-interactive (direct by ID): +npm run edit-action -- --action-id "create-task-entity" + +# Interactive (pick an action from a list): npm run edit-action + +# Optional flags: +# - skip generating app before editing +npm run edit-action -- --skip-generating-app +# - pass a custom Wasp CLI +npm run edit-action -- --wasp-cli-command wasp ``` This command: -- Generates the app (unless `--skip-generating-app` is used) +- Generates the app - Creates a branch from the action's commit -- Captures your changes as a new patch -- Updates all affected patch files +- Captures your edits as a new patch +- Rebases the fixes onto the main branch (prompts to resolve conflicts if needed) +- Regenerates patch files to reflect the updated history ### 3. List Actions (`npm run list-actions`) @@ -48,13 +63,14 @@ Displays all available tutorial actions organized by source file. npm run list-actions ``` +You will see actions grouped by tutorial markdown filename, including the action `id` and its `kind`. ### Patch File Management - Patch files are stored in the `./docs/tutorial/patches/` directory - Files are named after their action IDs (e.g., `create-task-entity.patch`) - Contains the Git diff for that specific action -- When a action is edited, all subsequent patch files are automatically regenerated +- When an action is edited, all patch files are automatically regenerated from the current commit history ### Database Migration Handling @@ -73,6 +89,7 @@ Tutorial actions are defined in markdown files using JSX-like components: In this action, we'll create the Task entity: + ```prisma model Task { id Int @id @default(autoincrement()) @@ -90,14 +107,14 @@ This Git-based approach ensures that: - **Changes can be made to any action** without breaking subsequent actions - **Conflicts are automatically handled** by Git's rebasing mechanism -## How It Works on Git Level, Patches, and Rebasing +## Extra Info: How It Works on Git Level, Patches, and Rebasing The tool uses a Git-based workflow to manage tutorial actions: ### Executing Tutorial Actions 1. **Initial Setup**: Creates a Git repository with an initial commit -2. **Action Execution**: Each action is executed and commited as a separate Git commit +2. **Action Execution**: Each action is executed and committed as a separate Git commit with the action ID as the commit message ### Action Editing Process @@ -160,4 +177,4 @@ git rebase fixes # This integrates the fixed action 4 # Regenerate all patch files from the updated commits # For each action after the edited one: git show action-commit-sha --format= > patches/action-N.patch -``` \ No newline at end of file +``` diff --git a/web/tutorial-actions-executor/src/actions/actions.ts b/web/tutorial-actions-executor/src/actions/actions.ts index 40b972b4de..87c2de0473 100644 --- a/web/tutorial-actions-executor/src/actions/actions.ts +++ b/web/tutorial-actions-executor/src/actions/actions.ts @@ -3,25 +3,25 @@ import type { Branded } from "../brandedTypes"; // If modify or add new action kinds, make sure to also update the type in `web/docs/tutorial/TutorialAction.tsx`. export type Action = InitAppAction | ApplyPatchAction | MigrateDbAction; -export type InitAppAction = { +export interface InitAppAction extends ActionCommon { kind: "INIT_APP"; waspStarterTemplateName: string; -} & ActionCommon; +} -export type ApplyPatchAction = { +export interface ApplyPatchAction extends ActionCommon { kind: "APPLY_PATCH"; displayName: string; patchFilePath: PatchFilePath; -} & ActionCommon; +} -export type MigrateDbAction = { +export interface MigrateDbAction extends ActionCommon { kind: "MIGRATE_DB"; -} & ActionCommon; +} -export type ActionCommon = { +export interface ActionCommon { id: ActionId; tutorialFilePath: MarkdownFilePath; -}; +} export type ActionId = Branded; export type PatchFilePath = Branded; diff --git a/web/tutorial-actions-executor/src/actions/git.ts b/web/tutorial-actions-executor/src/actions/git.ts index 37f15894c4..8e78d91fdb 100644 --- a/web/tutorial-actions-executor/src/actions/git.ts +++ b/web/tutorial-actions-executor/src/actions/git.ts @@ -2,7 +2,7 @@ import { confirm } from "@inquirer/prompts"; import parseGitDiff from "parse-git-diff"; import { fs } from "zx"; -import { askToOpenProjectInEditor } from "../editor"; +import { askToOpenTutorialAppInEditor } from "../editor"; import { applyPatch, commitAllChanges, @@ -80,7 +80,7 @@ export async function askUserToEditAndCreatePatch({ appDir: AppDirPath; action: ApplyPatchAction; }) { - await askToOpenProjectInEditor(); + await askToOpenTutorialAppInEditor(appDir); await confirm({ message: `Update the app according to action ${action.displayName} and press Enter`, }); @@ -111,7 +111,7 @@ export async function createBranchFromActionCommit({ }): Promise { const actionCommitSha = await getActionCommitSHA({ appDir, action }); await createBranchFromRevision({ - gitRepoDir: appDir, + gitRepoDirPath: appDir, branchName, revision: actionCommitSha, }); diff --git a/web/tutorial-actions-executor/src/actions/index.ts b/web/tutorial-actions-executor/src/actions/index.ts index 5202c930d2..7009a4fdb7 100644 --- a/web/tutorial-actions-executor/src/actions/index.ts +++ b/web/tutorial-actions-executor/src/actions/index.ts @@ -1,6 +1,6 @@ import path, { basename } from "path"; import { getFileNameWithoutExtension } from "../files"; -import { docsTutorialPatchesPath } from "../tutorialApp"; +import type { PatchesDirPath } from "../tutorialApp"; import type { ActionCommon, ApplyPatchAction, @@ -31,17 +31,20 @@ export function createMigrateDbAction( export function createApplyPatchAction( commonData: ActionCommon, + docsTutorialPatchesPath: PatchesDirPath, ): ApplyPatchAction { - const patchFilePath = getPatchFilePath(commonData); return { ...commonData, kind: "APPLY_PATCH", displayName: `${basename(commonData.tutorialFilePath)} / ${commonData.id}`, - patchFilePath, + patchFilePath: getPatchFilePath(commonData, docsTutorialPatchesPath), }; } -function getPatchFilePath(action: ActionCommon): PatchFilePath { +function getPatchFilePath( + action: ActionCommon, + docsTutorialPatchesPath: PatchesDirPath, +): PatchFilePath { const sourceFileName = getFileNameWithoutExtension(action.tutorialFilePath); const patchFileName = `${sourceFileName}__${action.id}.patch`; return path.resolve(docsTutorialPatchesPath, patchFileName) as PatchFilePath; diff --git a/web/tutorial-actions-executor/src/actions/init.ts b/web/tutorial-actions-executor/src/actions/init.ts index 50672c4f00..297afb1270 100644 --- a/web/tutorial-actions-executor/src/actions/init.ts +++ b/web/tutorial-actions-executor/src/actions/init.ts @@ -1,4 +1,4 @@ -import { $, fs } from "zx"; +import { fs } from "zx"; import { initGitRepo } from "../git"; import { log } from "../log"; @@ -7,25 +7,29 @@ import { type AppName, type AppParentDirPath, } from "../tutorialApp"; -import { waspNew } from "../waspCli"; +import { waspNew, type WaspCliCommand } from "../waspCli"; -export async function initApp({ - tutorialAppName, - tutorialAppDirPath, - tutorialAppParentDirPath, +export async function initWaspAppWithGitRepo({ + waspCliCommand, + appName, + appDirPath, + appParentDirPath, mainBranchName, }: { - tutorialAppName: AppName; - tutorialAppParentDirPath: AppParentDirPath; - tutorialAppDirPath: AppDirPath; + waspCliCommand: WaspCliCommand; + appName: AppName; + appParentDirPath: AppParentDirPath; + appDirPath: AppDirPath; mainBranchName: string; }): Promise { - await fs.ensureDir(tutorialAppParentDirPath); - await $`rm -rf ${tutorialAppDirPath}`; + await fs.ensureDir(appParentDirPath); + await fs.remove(appDirPath); + await waspNew({ - appName: tutorialAppName, - appParentDir: tutorialAppParentDirPath, + waspCliCommand, + appName, + appParentDirPath, }); - await initGitRepo(tutorialAppDirPath, mainBranchName); - log("info", `Tutorial app has been initialized in ${tutorialAppDirPath}`); + await initGitRepo(appDirPath, mainBranchName); + log("info", `Tutorial app has been initialized in ${appDirPath}`); } diff --git a/web/tutorial-actions-executor/src/commands/commonOptions.ts b/web/tutorial-actions-executor/src/commands/commonOptions.ts new file mode 100644 index 0000000000..1a63a0bf72 --- /dev/null +++ b/web/tutorial-actions-executor/src/commands/commonOptions.ts @@ -0,0 +1,6 @@ +import { Option } from "@commander-js/extra-typings"; + +export const waspCliCommandOption = new Option( + "--wasp-cli-command ", + "Wasp CLI command to use", +).default("wasp"); diff --git a/web/tutorial-actions-executor/src/commands/edit-action/index.ts b/web/tutorial-actions-executor/src/commands/edit-action/index.ts index fdcdece3ad..a933e55a00 100644 --- a/web/tutorial-actions-executor/src/commands/edit-action/index.ts +++ b/web/tutorial-actions-executor/src/commands/edit-action/index.ts @@ -19,11 +19,9 @@ import { rebaseBranch, } from "../../git"; import { log } from "../../log"; -import { - docsTutorialDirPath, - tutorialAppDirPath, - type AppDirPath, -} from "../../tutorialApp"; +import { tutorialApp, type AppDirPath } from "../../tutorialApp"; +import type { WaspCliCommand } from "../../waspCli"; +import { waspCliCommandOption } from "../commonOptions"; import { generateApp } from "../generate-app"; export const editActionCommand = new Command("edit-action") @@ -35,8 +33,9 @@ export const editActionCommand = new Command("edit-action") "Skip generating app before editing action", ), ) - .action(async ({ actionId, skipGeneratingApp }) => { - const actions = await getActionsFromTutorialFiles(docsTutorialDirPath); + .addOption(waspCliCommandOption) + .action(async ({ actionId, skipGeneratingApp, waspCliCommand }) => { + const actions = await getActionsFromTutorialFiles(tutorialApp); log("info", `Found ${actions.length} actions in tutorial files.`); const action = await ensureAction({ @@ -46,17 +45,17 @@ export const editActionCommand = new Command("edit-action") if (!skipGeneratingApp) { log("info", "Generating app before editing action..."); - await generateApp(actions); + await generateApp(actions, waspCliCommand as WaspCliCommand); } else { log( "info", - `Skipping app generation, using existing app in ${tutorialAppDirPath}`, + `Skipping app generation, using existing app in ${tutorialApp.dirPath}`, ); } log("info", `Editing action ${action.displayName}...`); - await editActionPatch({ appDir: tutorialAppDirPath, action }); + await editActionPatch({ appDir: tutorialApp.dirPath, action }); await extractCommitsIntoPatches(actions); @@ -85,7 +84,7 @@ async function editActionPatch({ try { await rebaseBranch({ - gitRepoDir: appDir, + gitRepoDirPath: appDir, branchName: fixesBranchName, baseBranchName: mainBranchName, }); @@ -111,7 +110,7 @@ async function extractCommitsIntoPatches(actions: Action[]): Promise { for (const action of applyPatchActions) { log("info", `Updating patch for action ${action.displayName}`); const patch = await generatePatchForAction({ - appDir: tutorialAppDirPath, + appDir: tutorialApp.dirPath, action, }); await fs.writeFile(action.patchFilePath, patch, "utf-8"); diff --git a/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts index 9fdd21d04b..aea0adf4ea 100644 --- a/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts +++ b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts @@ -6,67 +6,63 @@ import { commitActionChanges, regeneratePatchForAction, } from "../../actions/git"; -import { initApp } from "../../actions/init"; +import { initWaspAppWithGitRepo } from "../../actions/init"; import { mainBranchName } from "../../git"; import { log } from "../../log"; -import { - tutorialAppDirPath, - tutorialAppName, - tutorialAppParentDirPath, - type AppDirPath, - type PatchesDirPath, -} from "../../tutorialApp"; -import { waspDbMigrate } from "../../waspCli"; +import type { TutorialApp } from "../../tutorialApp"; +import { waspDbMigrate, type WaspCliCommand } from "../../waspCli"; export async function executeActions({ - appDir, - patchesDir, + tutorialApp, actions, + waspCliCommand, }: { - appDir: AppDirPath; - patchesDir: PatchesDirPath; + tutorialApp: TutorialApp; actions: Action[]; + waspCliCommand: WaspCliCommand; }): Promise { for (const action of actions) { log("info", `${chalk.bold(`[action ${action.id}]`)} ${action.kind}`); - await fs.ensureDir(patchesDir); + await fs.ensureDir(tutorialApp.docsTutorialPatchesPath); - try { - switch (action.kind) { - case "INIT_APP": - await spinner("Initializing the tutorial app...", () => - initApp({ - tutorialAppDirPath, - tutorialAppParentDirPath, - tutorialAppName, - mainBranchName, - }), + switch (action.kind) { + case "INIT_APP": + await spinner("Initializing the tutorial app...", () => + initWaspAppWithGitRepo({ + waspCliCommand, + appName: tutorialApp.name, + appParentDirPath: tutorialApp.parentDirPath, + appDirPath: tutorialApp.dirPath, + mainBranchName, + }), + ); + break; + case "APPLY_PATCH": + try { + await applyPatchForAction({ appDir: tutorialApp.dirPath, action }); + } catch (err) { + log( + "error", + `Failed to apply patch for action ${action.displayName}:\n${err}`, ); - break; - case "APPLY_PATCH": - try { - await applyPatchForAction({ appDir, action }); - } catch (err) { - log( - "error", - `Failed to apply patch for action ${action.displayName}:\n${err}`, - ); - await regeneratePatchForAction({ appDir, action }); - await applyPatchForAction({ appDir, action }); - } - await commitActionChanges({ appDir, action }); - break; - case "MIGRATE_DB": - await waspDbMigrate(appDir, action.id); - await commitActionChanges({ appDir, action }); - break; - default: - action satisfies never; - } - } catch (err) { - log("error", `Error in action with ID ${action.id}:\n\n${err}`); - process.exit(1); + await regeneratePatchForAction({ + appDir: tutorialApp.dirPath, + action, + }); + await applyPatchForAction({ appDir: tutorialApp.dirPath, action }); + } + break; + case "MIGRATE_DB": + await waspDbMigrate({ + waspCliCommand, + appDir: tutorialApp.dirPath, + migrationName: action.id, + }); + break; + default: + action satisfies never; } + await commitActionChanges({ appDir: tutorialApp.dirPath, action }); } } diff --git a/web/tutorial-actions-executor/src/commands/generate-app/index.ts b/web/tutorial-actions-executor/src/commands/generate-app/index.ts index ebb2e2bd94..11dfa56a6f 100644 --- a/web/tutorial-actions-executor/src/commands/generate-app/index.ts +++ b/web/tutorial-actions-executor/src/commands/generate-app/index.ts @@ -3,30 +3,33 @@ import { Command } from "@commander-js/extra-typings"; import type { Action } from "../../actions/actions"; import { getActionsFromTutorialFiles } from "../../extract-actions"; import { log } from "../../log"; -import { - docsTutorialDirPath, - docsTutorialPatchesPath, - tutorialAppDirPath, -} from "../../tutorialApp"; + +import { tutorialApp } from "../../tutorialApp"; +import type { WaspCliCommand } from "../../waspCli"; +import { waspCliCommandOption } from "../commonOptions"; import { executeActions } from "./execute-actions"; export const generateAppCommand = new Command("generate-app") .description("Generate a new Wasp app based on the tutorial actions") - .action(async () => { - const actions = await getActionsFromTutorialFiles(docsTutorialDirPath); + .addOption(waspCliCommandOption) + .action(async ({ waspCliCommand }) => { + const actions = await getActionsFromTutorialFiles(tutorialApp); log("info", `Found ${actions.length} actions in tutorial files.`); - await generateApp(actions); + await generateApp(actions, waspCliCommand as WaspCliCommand); }); -export async function generateApp(actions: Action[]): Promise { +export async function generateApp( + actions: Action[], + waspCliCommand: WaspCliCommand, +): Promise { await executeActions({ - appDir: tutorialAppDirPath, - patchesDir: docsTutorialPatchesPath, + waspCliCommand, + tutorialApp, actions, }); log( "success", - `Tutorial app has been successfully generated in ${tutorialAppDirPath}`, + `Tutorial app has been successfully generated in ${tutorialApp.dirPath}`, ); } diff --git a/web/tutorial-actions-executor/src/commands/list-actions/index.ts b/web/tutorial-actions-executor/src/commands/list-actions/index.ts index 8a2c3d4213..f03d9f8da2 100644 --- a/web/tutorial-actions-executor/src/commands/list-actions/index.ts +++ b/web/tutorial-actions-executor/src/commands/list-actions/index.ts @@ -4,20 +4,21 @@ import { chalk } from "zx"; import type { Action } from "../../actions/actions"; import { getActionsFromTutorialFiles } from "../../extract-actions"; -import { docsTutorialDirPath } from "../../tutorialApp"; +import { tutorialApp } from "../../tutorialApp"; -type ActionsGroupedByFile = Map; +type SourceFileName = string; +type ActionsGroupedByFile = Map; export const listActionsCommand = new Command("list-actions") .description("List all actions in the tutorial") .action(async () => { - const actions = await getActionsFromTutorialFiles(docsTutorialDirPath); + const actions = await getActionsFromTutorialFiles(tutorialApp); const actionsGroupedByFile = groupActionsBySourceFile(actions); displayGroupedActions(actionsGroupedByFile); }); function groupActionsBySourceFile(actions: Action[]): ActionsGroupedByFile { - const groupedActions = new Map(); + const groupedActions = new Map(); for (const action of actions) { const filename = basename(action.tutorialFilePath); @@ -34,7 +35,6 @@ function displayGroupedActions( for (const [filename, fileActions] of actionsGroupedByFile) { displayFileHeader(filename); displayActionsForFile(fileActions); - console.log(); } } @@ -53,7 +53,6 @@ function displayActionsForFile(actions: Action[]): void { actions.forEach((action) => { const kindColorFn = kindColorMap[action.kind]; - console.log(`- ${chalk.bold(action.id)} (${kindColorFn(action.kind)})`); }); } diff --git a/web/tutorial-actions-executor/src/editor.ts b/web/tutorial-actions-executor/src/editor.ts index 7c96b2b04e..af35cf7d2b 100644 --- a/web/tutorial-actions-executor/src/editor.ts +++ b/web/tutorial-actions-executor/src/editor.ts @@ -1,16 +1,18 @@ import { confirm } from "@inquirer/prompts"; import { $ } from "zx"; -import { tutorialAppDirPath } from "./tutorialApp"; +import type { AppDirPath } from "./tutorialApp"; -export async function askToOpenProjectInEditor() { +export async function askToOpenTutorialAppInEditor( + appDirPath: AppDirPath, +): Promise { const editor = process.env.EDITOR; if (!editor) { return; } const wantsToOpenVSCode = await confirm({ - message: `Do you want to open the app in ${editor}?`, + message: `Do you want to open the tutorial app in ${editor}? (Found in $EDITOR env variable)`, }); if (wantsToOpenVSCode) { - await $`$EDITOR ${tutorialAppDirPath}`; + await $`$EDITOR ${appDirPath}`; } } diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 5f15ee5c30..852df78bfc 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -18,16 +18,18 @@ import { createInitAppAction, createMigrateDbAction, } from "../actions/index.js"; -import type { TutorialDirPath } from "../tutorialApp.js"; +import type { TutorialApp, TutorialDirPath } from "../tutorialApp.js"; export async function getActionsFromTutorialFiles( - tutorialDir: TutorialDirPath, + tutorialApp: TutorialApp, ): Promise { - const tutorialFilePaths = await getTutorialFilePaths(tutorialDir); + const tutorialFilePaths = await getTutorialFilePaths( + tutorialApp.docsTutorialDirPath, + ); const actions: Action[] = []; for (const filePath of tutorialFilePaths) { - const fileActions = await getActionsFromMarkdownFile(filePath); + const fileActions = await getActionsFromMarkdownFile(filePath, tutorialApp); actions.push(...fileActions); } @@ -54,6 +56,7 @@ async function getTutorialFilePaths( async function getActionsFromMarkdownFile( tutorialFilePath: MarkdownFilePath, + tutorialApp: TutorialApp, ): Promise { const actions: Action[] = []; const fileContent = await fs.readFile(path.resolve(tutorialFilePath)); @@ -103,7 +106,12 @@ async function getActionsFromMarkdownFile( actions.push(createInitAppAction(commonData, waspStarterTemplateName)); break; case "APPLY_PATCH": - actions.push(createApplyPatchAction(commonData)); + actions.push( + createApplyPatchAction( + commonData, + tutorialApp.docsTutorialPatchesPath, + ), + ); break; case "MIGRATE_DB": actions.push(createMigrateDbAction(commonData)); diff --git a/web/tutorial-actions-executor/src/git.ts b/web/tutorial-actions-executor/src/git.ts index bedabb3928..ed7fd364a9 100644 --- a/web/tutorial-actions-executor/src/git.ts +++ b/web/tutorial-actions-executor/src/git.ts @@ -3,23 +3,25 @@ import { $ } from "zx"; export const mainBranchName = "main"; export async function generatePatchFromAllChanges( - gitRepoDir: string, + gitRepoDirPath: string, ): Promise { - await commitAllChanges(gitRepoDir, "temporary-commit"); - const patch = await generatePatchFromRevision(gitRepoDir, "HEAD"); - await removeLastCommit(gitRepoDir); + await commitAllChanges(gitRepoDirPath, "temporary-commit"); + const patch = await generatePatchFromRevision(gitRepoDirPath, "HEAD"); + await removeLastCommit(gitRepoDirPath); return patch; } -export async function applyPatch(gitRepoDir: string, patchPath: string) { - await $({ cwd: gitRepoDir })`git apply ${patchPath} --verbose`.quiet(true); +export async function applyPatch(gitRepoDirPath: string, patchPath: string) { + await $({ cwd: gitRepoDirPath })`git apply ${patchPath} --verbose`.quiet( + true, + ); } export async function findCommitSHAForExactMessage( - gitRepoDir: string, + gitRepoDirPath: string, message: string, ): Promise { - const commits = await grepGitCommitMessages(gitRepoDir, message); + const commits = await grepGitCommitsByMessage(gitRepoDirPath, message); const commit = commits.find((commit) => commit.message === message); if (!commit) { @@ -34,13 +36,13 @@ type GitCommit = { sha: string; }; -async function grepGitCommitMessages( - gitRepoDir: string, +async function grepGitCommitsByMessage( + gitRepoDirPath: string, message: string, ): Promise { const format = `{"message":"%s","sha":"%H"}`; const { stdout } = await $({ - cwd: gitRepoDir, + cwd: gitRepoDirPath, })`git log --branches --format=${format} --grep=${message}`; const commits = stdout.split("\n").filter((line) => line.trim() !== ""); @@ -54,67 +56,65 @@ async function grepGitCommitMessages( } export async function commitAllChanges( - gitRepoDir: string, + gitRepoDirPath: string, message: string, ): Promise { - await $({ cwd: gitRepoDir })`git add .`; - await $({ cwd: gitRepoDir })`git commit -m ${message}`; + await $({ cwd: gitRepoDirPath })`git add .`; + await $({ cwd: gitRepoDirPath })`git commit -m ${message}`; } -async function removeLastCommit(gitRepoDir: string): Promise { - await $({ cwd: gitRepoDir })`git reset --hard HEAD~1`; +async function removeLastCommit(gitRepoDirPath: string): Promise { + await $({ cwd: gitRepoDirPath })`git reset --hard HEAD~1`; } export async function generatePatchFromRevision( - gitRepoDir: string, + gitRepoDirPath: string, gitRevision: string, ): Promise { const { stdout: patch } = await $({ - cwd: gitRepoDir, + cwd: gitRepoDirPath, })`git show ${gitRevision} --format=`; return patch; } export async function initGitRepo( - gitRepoDir: string, + gitRepoDirPath: string, mainBranchName: string, ): Promise { - await $({ cwd: gitRepoDir })`git init`.quiet(true); - await $({ cwd: gitRepoDir })`git branch -m ${mainBranchName}`; - await $({ cwd: gitRepoDir })`git add .`; - await $({ cwd: gitRepoDir })`git commit -m "Initial commit"`; + await $({ cwd: gitRepoDirPath })`git init`.quiet(true); + await $({ cwd: gitRepoDirPath })`git branch -m ${mainBranchName}`; } export async function createBranchFromRevision({ - gitRepoDir, + gitRepoDirPath, branchName, revision, }: { - gitRepoDir: string; + gitRepoDirPath: string; branchName: string; revision: string; }): Promise { await $({ - cwd: gitRepoDir, + cwd: gitRepoDirPath, })`git switch --force-create ${branchName} ${revision}`; } export async function moveLastCommitChangesToStaging( - gitRepoDir: string, + gitRepoDirPath: string, ): Promise { - await $({ cwd: gitRepoDir })`git reset --soft HEAD~1`; + await $({ cwd: gitRepoDirPath })`git reset --soft HEAD~1`; } export async function rebaseBranch({ - gitRepoDir, + gitRepoDirPath, branchName, baseBranchName, }: { - gitRepoDir: string; + gitRepoDirPath: string; branchName: string; baseBranchName: string; }): Promise { - await $({ cwd: gitRepoDir })`git switch ${baseBranchName}`; - await $({ cwd: gitRepoDir })`git rebase ${branchName}`; + await $({ cwd: gitRepoDirPath })`git switch ${baseBranchName}`; + await $({ cwd: gitRepoDirPath })`git rebase ${branchName}`; } diff --git a/web/tutorial-actions-executor/src/tutorialApp.ts b/web/tutorial-actions-executor/src/tutorialApp.ts index 8f863bc75e..cfaed54867 100644 --- a/web/tutorial-actions-executor/src/tutorialApp.ts +++ b/web/tutorial-actions-executor/src/tutorialApp.ts @@ -8,18 +8,26 @@ export type AppParentDirPath = Branded; export type TutorialDirPath = Branded; export type PatchesDirPath = Branded; -export const tutorialAppName = "TodoApp" as AppName; -export const tutorialAppParentDirPath = path.resolve( +const tutorialAppName = "TodoApp" as AppName; +const tutorialAppParentDirPath = path.resolve( "./.generated-apps", ) as AppParentDirPath; -export const tutorialAppDirPath = path.resolve( +const tutorialAppDirPath = path.resolve( tutorialAppParentDirPath, tutorialAppName, ) as AppDirPath; -export const docsTutorialDirPath = path.resolve( - "../docs/tutorial", -) as TutorialDirPath; -export const docsTutorialPatchesPath = path.resolve( +const docsTutorialDirPath = path.resolve("../docs/tutorial") as TutorialDirPath; +const docsTutorialPatchesPath = path.resolve( docsTutorialDirPath, "patches", ) as PatchesDirPath; + +export const tutorialApp = { + name: tutorialAppName, + parentDirPath: tutorialAppParentDirPath, + dirPath: tutorialAppDirPath, + docsTutorialDirPath, + docsTutorialPatchesPath, +} as const; + +export type TutorialApp = typeof tutorialApp; diff --git a/web/tutorial-actions-executor/src/waspCli.ts b/web/tutorial-actions-executor/src/waspCli.ts index 98f6886fda..8ea9432535 100644 --- a/web/tutorial-actions-executor/src/waspCli.ts +++ b/web/tutorial-actions-executor/src/waspCli.ts @@ -1,25 +1,35 @@ import { $ } from "zx"; +import type { Branded } from "./brandedTypes"; import type { AppDirPath, AppName, AppParentDirPath } from "./tutorialApp"; -export async function waspDbMigrate( - appDir: AppDirPath, - migrationName: string, -): Promise { +export type WaspCliCommand = Branded; + +export async function waspDbMigrate({ + waspCliCommand, + appDir, + migrationName, +}: { + waspCliCommand: WaspCliCommand; + appDir: AppDirPath; + migrationName: string; +}): Promise { await $({ // Needs to inhert stdio for `wasp db migrate-dev` to work stdio: "inherit", cwd: appDir, - })`wasp db migrate-dev --name ${migrationName}`; + })`${waspCliCommand} db migrate-dev --name ${migrationName}`; } export async function waspNew({ + waspCliCommand, appName, - appParentDir, + appParentDirPath, }: { + waspCliCommand: WaspCliCommand; appName: AppName; - appParentDir: AppParentDirPath; + appParentDirPath: AppParentDirPath; }): Promise { await $({ - cwd: appParentDir, - })`wasp new ${appName} -t minimal`; + cwd: appParentDirPath, + })`${waspCliCommand} new ${appName} -t minimal`; } From 1814a7235fba0f262ee2894418febe8c6920e1bc Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 15 Sep 2025 10:22:48 +0200 Subject: [PATCH 33/48] Update README --- web/tutorial-actions-executor/README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index ce6cea3a74..26ab43cd27 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -1,10 +1,17 @@ # Tutorial Actions Executor (tacte) -A CLI tool for managing and editing tutorial actions in Wasp docs. This tool allows you to execute all tutorial actions to get the final app or edit individual actions. +## What is this CLI? + +Wasp docs have a tutorial that walks users through building a complete Wasp application step-by-step. + +Next to the text that explains each step, we added the `` components that define actions +that define machine executable steps, like "create a new Wasp app", "add authentication", "create a Task entity", etc. +This CLI tool, reads those tutorial files, extracts the actions, and executes them in sequence +to create a fully functional Wasp application. ## Commands -The CLI provides three main commands: +The CLI provides three commands: ### 1. Generate App (`npm run generate-app`) @@ -49,7 +56,7 @@ npm run edit-action -- --wasp-cli-command wasp This command: -- Generates the app +- Generates the app (unless `--skip-generating-app`) - Creates a branch from the action's commit - Captures your edits as a new patch - Rebases the fixes onto the main branch (prompts to resolve conflicts if needed) @@ -68,15 +75,15 @@ You will see actions grouped by tutorial markdown filename, including the action ### Patch File Management - Patch files are stored in the `./docs/tutorial/patches/` directory -- Files are named after their action IDs (e.g., `create-task-entity.patch`) -- Contains the Git diff for that specific action +- Files are named based on the source markdown file and the action id +- Each file contains the Git diff for that specific action - When an action is edited, all patch files are automatically regenerated from the current commit history ### Database Migration Handling For actions that involve database changes: -- Uses `wasp db migrate` command instead of patch application +- Uses `wasp db migrate-dev --name ` instead of applying a patch - Migration files are committed with the action ID as the commit message ### Tutorial File Format From ec4ec934c83ff4935d0173c73aa2bab7dbfcd722 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 29 Sep 2025 14:14:50 +0200 Subject: [PATCH 34/48] PR comments --- .../src/actions/actions.ts | 10 +++++----- web/tutorial-actions-executor/src/actions/index.ts | 13 ++++++------- web/tutorial-actions-executor/src/assert.ts | 3 +++ .../src/commands/commonOptions.ts | 6 +++++- .../src/commands/generate-app/execute-actions.ts | 3 ++- .../src/commands/generate-app/index.ts | 3 +-- .../src/commands/list-actions/index.ts | 3 ++- web/tutorial-actions-executor/src/editor.ts | 1 + .../src/extract-actions/index.ts | 9 +++++---- web/tutorial-actions-executor/src/git.ts | 4 ++-- web/tutorial-actions-executor/src/index.ts | 1 + web/tutorial-actions-executor/src/waspCli.ts | 1 + 12 files changed, 34 insertions(+), 23 deletions(-) create mode 100644 web/tutorial-actions-executor/src/assert.ts diff --git a/web/tutorial-actions-executor/src/actions/actions.ts b/web/tutorial-actions-executor/src/actions/actions.ts index 87c2de0473..e8643b4c17 100644 --- a/web/tutorial-actions-executor/src/actions/actions.ts +++ b/web/tutorial-actions-executor/src/actions/actions.ts @@ -1,24 +1,24 @@ import type { Branded } from "../brandedTypes"; -// If modify or add new action kinds, make sure to also update the type in `web/docs/tutorial/TutorialAction.tsx`. +// If you modify or add new action kinds, make sure to also update the type in `web/docs/tutorial/TutorialAction.tsx`. export type Action = InitAppAction | ApplyPatchAction | MigrateDbAction; -export interface InitAppAction extends ActionCommon { +export interface InitAppAction extends BaseAction { kind: "INIT_APP"; waspStarterTemplateName: string; } -export interface ApplyPatchAction extends ActionCommon { +export interface ApplyPatchAction extends BaseAction { kind: "APPLY_PATCH"; displayName: string; patchFilePath: PatchFilePath; } -export interface MigrateDbAction extends ActionCommon { +export interface MigrateDbAction extends BaseAction { kind: "MIGRATE_DB"; } -export interface ActionCommon { +export interface BaseAction { id: ActionId; tutorialFilePath: MarkdownFilePath; } diff --git a/web/tutorial-actions-executor/src/actions/index.ts b/web/tutorial-actions-executor/src/actions/index.ts index 7009a4fdb7..0ae0879183 100644 --- a/web/tutorial-actions-executor/src/actions/index.ts +++ b/web/tutorial-actions-executor/src/actions/index.ts @@ -1,16 +1,17 @@ import path, { basename } from "path"; + import { getFileNameWithoutExtension } from "../files"; import type { PatchesDirPath } from "../tutorialApp"; import type { - ActionCommon, ApplyPatchAction, + BaseAction, InitAppAction, MigrateDbAction, PatchFilePath, } from "./actions"; export function createInitAppAction( - commonData: ActionCommon, + commonData: BaseAction, waspStarterTemplateName: string, ): InitAppAction { return { @@ -20,9 +21,7 @@ export function createInitAppAction( }; } -export function createMigrateDbAction( - commonData: ActionCommon, -): MigrateDbAction { +export function createMigrateDbAction(commonData: BaseAction): MigrateDbAction { return { ...commonData, kind: "MIGRATE_DB", @@ -30,7 +29,7 @@ export function createMigrateDbAction( } export function createApplyPatchAction( - commonData: ActionCommon, + commonData: BaseAction, docsTutorialPatchesPath: PatchesDirPath, ): ApplyPatchAction { return { @@ -42,7 +41,7 @@ export function createApplyPatchAction( } function getPatchFilePath( - action: ActionCommon, + action: BaseAction, docsTutorialPatchesPath: PatchesDirPath, ): PatchFilePath { const sourceFileName = getFileNameWithoutExtension(action.tutorialFilePath); diff --git a/web/tutorial-actions-executor/src/assert.ts b/web/tutorial-actions-executor/src/assert.ts new file mode 100644 index 0000000000..0432db3aa3 --- /dev/null +++ b/web/tutorial-actions-executor/src/assert.ts @@ -0,0 +1,3 @@ +export function assertUnreachable(_x: never, message: string): never { + throw new Error(message); +} diff --git a/web/tutorial-actions-executor/src/commands/commonOptions.ts b/web/tutorial-actions-executor/src/commands/commonOptions.ts index 1a63a0bf72..7a55debd4e 100644 --- a/web/tutorial-actions-executor/src/commands/commonOptions.ts +++ b/web/tutorial-actions-executor/src/commands/commonOptions.ts @@ -1,6 +1,10 @@ import { Option } from "@commander-js/extra-typings"; +import type { WaspCliCommand } from "../waspCli"; + export const waspCliCommandOption = new Option( "--wasp-cli-command ", "Wasp CLI command to use", -).default("wasp"); +) + .default("wasp" as WaspCliCommand) + .argParser((value) => value as WaspCliCommand); diff --git a/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts index aea0adf4ea..e288b32f81 100644 --- a/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts +++ b/web/tutorial-actions-executor/src/commands/generate-app/execute-actions.ts @@ -7,6 +7,7 @@ import { regeneratePatchForAction, } from "../../actions/git"; import { initWaspAppWithGitRepo } from "../../actions/init"; +import { assertUnreachable } from "../../assert"; import { mainBranchName } from "../../git"; import { log } from "../../log"; import type { TutorialApp } from "../../tutorialApp"; @@ -61,7 +62,7 @@ export async function executeActions({ }); break; default: - action satisfies never; + assertUnreachable(action, `Unknown action '${action}'`); } await commitActionChanges({ appDir: tutorialApp.dirPath, action }); } diff --git a/web/tutorial-actions-executor/src/commands/generate-app/index.ts b/web/tutorial-actions-executor/src/commands/generate-app/index.ts index 11dfa56a6f..f4ac3168c7 100644 --- a/web/tutorial-actions-executor/src/commands/generate-app/index.ts +++ b/web/tutorial-actions-executor/src/commands/generate-app/index.ts @@ -3,7 +3,6 @@ import { Command } from "@commander-js/extra-typings"; import type { Action } from "../../actions/actions"; import { getActionsFromTutorialFiles } from "../../extract-actions"; import { log } from "../../log"; - import { tutorialApp } from "../../tutorialApp"; import type { WaspCliCommand } from "../../waspCli"; import { waspCliCommandOption } from "../commonOptions"; @@ -16,7 +15,7 @@ export const generateAppCommand = new Command("generate-app") const actions = await getActionsFromTutorialFiles(tutorialApp); log("info", `Found ${actions.length} actions in tutorial files.`); - await generateApp(actions, waspCliCommand as WaspCliCommand); + await generateApp(actions, waspCliCommand); }); export async function generateApp( diff --git a/web/tutorial-actions-executor/src/commands/list-actions/index.ts b/web/tutorial-actions-executor/src/commands/list-actions/index.ts index f03d9f8da2..80961261f4 100644 --- a/web/tutorial-actions-executor/src/commands/list-actions/index.ts +++ b/web/tutorial-actions-executor/src/commands/list-actions/index.ts @@ -1,5 +1,6 @@ -import { Command } from "@commander-js/extra-typings"; import { basename } from "path"; + +import { Command } from "@commander-js/extra-typings"; import { chalk } from "zx"; import type { Action } from "../../actions/actions"; diff --git a/web/tutorial-actions-executor/src/editor.ts b/web/tutorial-actions-executor/src/editor.ts index af35cf7d2b..93e4ed5c20 100644 --- a/web/tutorial-actions-executor/src/editor.ts +++ b/web/tutorial-actions-executor/src/editor.ts @@ -1,5 +1,6 @@ import { confirm } from "@inquirer/prompts"; import { $ } from "zx"; + import type { AppDirPath } from "./tutorialApp"; export async function askToOpenTutorialAppInEditor( diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 852df78bfc..79f1089505 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -9,8 +9,8 @@ import { visit } from "unist-util-visit"; import type { Action, - ActionCommon, ActionId, + BaseAction, MarkdownFilePath, } from "../actions/actions.js"; import { @@ -18,6 +18,7 @@ import { createInitAppAction, createMigrateDbAction, } from "../actions/index.js"; +import { assertUnreachable } from "../assert.js"; import type { TutorialApp, TutorialDirPath } from "../tutorialApp.js"; export async function getActionsFromTutorialFiles( @@ -92,7 +93,7 @@ async function getActionsFromMarkdownFile( ); } - const commonData: ActionCommon = { + const commonData: BaseAction = { id: actionId, tutorialFilePath, }; @@ -117,8 +118,8 @@ async function getActionsFromMarkdownFile( actions.push(createMigrateDbAction(commonData)); break; default: - actionName satisfies never; - throw new Error( + assertUnreachable( + actionName, `Unknown action '${actionName}' in TutorialAction component. File: ${tutorialFilePath}`, ); } diff --git a/web/tutorial-actions-executor/src/git.ts b/web/tutorial-actions-executor/src/git.ts index ed7fd364a9..8a30ba91db 100644 --- a/web/tutorial-actions-executor/src/git.ts +++ b/web/tutorial-actions-executor/src/git.ts @@ -31,10 +31,10 @@ export async function findCommitSHAForExactMessage( return commit.sha; } -type GitCommit = { +interface GitCommit { message: string; sha: string; -}; +} async function grepGitCommitsByMessage( gitRepoDirPath: string, diff --git a/web/tutorial-actions-executor/src/index.ts b/web/tutorial-actions-executor/src/index.ts index 11854845da..eae44c4432 100644 --- a/web/tutorial-actions-executor/src/index.ts +++ b/web/tutorial-actions-executor/src/index.ts @@ -1,4 +1,5 @@ import { program } from "@commander-js/extra-typings"; + import { editActionCommand } from "./commands/edit-action"; import { generateAppCommand } from "./commands/generate-app"; import { listActionsCommand } from "./commands/list-actions"; diff --git a/web/tutorial-actions-executor/src/waspCli.ts b/web/tutorial-actions-executor/src/waspCli.ts index 8ea9432535..f43a0126d1 100644 --- a/web/tutorial-actions-executor/src/waspCli.ts +++ b/web/tutorial-actions-executor/src/waspCli.ts @@ -1,4 +1,5 @@ import { $ } from "zx"; + import type { Branded } from "./brandedTypes"; import type { AppDirPath, AppName, AppParentDirPath } from "./tutorialApp"; From fe586464af8774e1bb0a53169c96a6b7b2160957 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 29 Sep 2025 14:33:09 +0200 Subject: [PATCH 35/48] PR comments --- web/docs/tutorial/TutorialAction.tsx | 6 ++---- web/tutorial-actions-executor/README.md | 10 +++++----- .../src/actions/actions.ts | 4 ++-- .../src/actions/index.ts | 19 ++++++++++--------- .../src/commands/edit-action/index.ts | 4 +++- .../src/extract-actions/index.ts | 12 ++++++------ 6 files changed, 28 insertions(+), 27 deletions(-) diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index b8c4e1dfe2..775550493b 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -23,9 +23,7 @@ type ActionProps = export function TutorialAction({ id, action }: { id: string } & ActionProps) { return ( process.env.NODE_ENV !== "production" && ( -
- -
+ ) ); } @@ -38,7 +36,7 @@ function TutorialActionDebug({ action: ActionProps["action"]; }) { return ( -
+
tutorial action: {action}
{id} diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index 26ab43cd27..2af98f34a4 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -26,8 +26,8 @@ npm run generate-app -- --wasp-cli-command wasp This command: - Initializes a new Wasp application -- Reads all tutorial markdown files (numbered like `01-setup.md`, `02-auth.md`, etc.) -- Extracts `` components from the markdown +- Reads all tutorial files (numbered like `01-setup.md`, `02-auth.md`, etc.) +- Extracts `` components from the file - Applies each action's patches or database migrations in order - Creates a Git commit for each action - Results in a fully functional application @@ -70,12 +70,12 @@ Displays all available tutorial actions organized by source file. npm run list-actions ``` -You will see actions grouped by tutorial markdown filename, including the action `id` and its `kind`. +You will see actions grouped by tutorial filename, including the action `id` and its `kind`. ### Patch File Management - Patch files are stored in the `./docs/tutorial/patches/` directory -- Files are named based on the source markdown file and the action id +- Files are named based on the source file and the action id - Each file contains the Git diff for that specific action - When an action is edited, all patch files are automatically regenerated from the current commit history @@ -88,7 +88,7 @@ For actions that involve database changes: ### Tutorial File Format -Tutorial actions are defined in markdown files using JSX-like components: +Tutorial actions are defined in MDX files using JSX components: ````markdown # Step 4: Create Task Entity diff --git a/web/tutorial-actions-executor/src/actions/actions.ts b/web/tutorial-actions-executor/src/actions/actions.ts index e8643b4c17..24e7c9dc7a 100644 --- a/web/tutorial-actions-executor/src/actions/actions.ts +++ b/web/tutorial-actions-executor/src/actions/actions.ts @@ -20,9 +20,9 @@ export interface MigrateDbAction extends BaseAction { export interface BaseAction { id: ActionId; - tutorialFilePath: MarkdownFilePath; + tutorialFilePath: MdxFilePath; } export type ActionId = Branded; export type PatchFilePath = Branded; -export type MarkdownFilePath = Branded; +export type MdxFilePath = Branded; diff --git a/web/tutorial-actions-executor/src/actions/index.ts b/web/tutorial-actions-executor/src/actions/index.ts index 0ae0879183..4b5ab368ab 100644 --- a/web/tutorial-actions-executor/src/actions/index.ts +++ b/web/tutorial-actions-executor/src/actions/index.ts @@ -3,9 +3,11 @@ import path, { basename } from "path"; import { getFileNameWithoutExtension } from "../files"; import type { PatchesDirPath } from "../tutorialApp"; import type { + ActionId, ApplyPatchAction, BaseAction, InitAppAction, + MdxFilePath, MigrateDbAction, PatchFilePath, } from "./actions"; @@ -30,21 +32,20 @@ export function createMigrateDbAction(commonData: BaseAction): MigrateDbAction { export function createApplyPatchAction( commonData: BaseAction, - docsTutorialPatchesPath: PatchesDirPath, + patchesDirPath: PatchesDirPath, ): ApplyPatchAction { + const patchFilename = getPatchFilename( + commonData.id, + commonData.tutorialFilePath, + ); return { ...commonData, kind: "APPLY_PATCH", displayName: `${basename(commonData.tutorialFilePath)} / ${commonData.id}`, - patchFilePath: getPatchFilePath(commonData, docsTutorialPatchesPath), + patchFilePath: path.resolve(patchesDirPath, patchFilename) as PatchFilePath, }; } -function getPatchFilePath( - action: BaseAction, - docsTutorialPatchesPath: PatchesDirPath, -): PatchFilePath { - const sourceFileName = getFileNameWithoutExtension(action.tutorialFilePath); - const patchFileName = `${sourceFileName}__${action.id}.patch`; - return path.resolve(docsTutorialPatchesPath, patchFileName) as PatchFilePath; +function getPatchFilename(id: ActionId, tutorialFilePath: MdxFilePath): string { + return `${getFileNameWithoutExtension(tutorialFilePath)}__${id}.patch`; } diff --git a/web/tutorial-actions-executor/src/commands/edit-action/index.ts b/web/tutorial-actions-executor/src/commands/edit-action/index.ts index a933e55a00..a7f6db3ac8 100644 --- a/web/tutorial-actions-executor/src/commands/edit-action/index.ts +++ b/web/tutorial-actions-executor/src/commands/edit-action/index.ts @@ -151,5 +151,7 @@ async function askUserToSelectAction( value: action.id, })), }); - return actions.find((a) => a.id === selectedActionId) as ApplyPatchAction; + return actions.find( + (a) => a.kind === "APPLY_PATCH" && a.id === selectedActionId, + )!; } diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 79f1089505..213b389a05 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -11,7 +11,7 @@ import type { Action, ActionId, BaseAction, - MarkdownFilePath, + MdxFilePath, } from "../actions/actions.js"; import { createApplyPatchAction, @@ -30,7 +30,7 @@ export async function getActionsFromTutorialFiles( const actions: Action[] = []; for (const filePath of tutorialFilePaths) { - const fileActions = await getActionsFromMarkdownFile(filePath, tutorialApp); + const fileActions = await getActionsFromMdxFile(filePath, tutorialApp); actions.push(...fileActions); } @@ -39,7 +39,7 @@ export async function getActionsFromTutorialFiles( async function getTutorialFilePaths( tutorialDir: TutorialDirPath, -): Promise { +): Promise { const files = await fs.readdir(tutorialDir); return ( files @@ -51,12 +51,12 @@ async function getTutorialFilePaths( const bNumber = parseInt(b.split("-")[0]!, 10); return aNumber - bNumber; }) - .map((file) => path.resolve(tutorialDir, file) as MarkdownFilePath) + .map((file) => path.resolve(tutorialDir, file) as MdxFilePath) ); } -async function getActionsFromMarkdownFile( - tutorialFilePath: MarkdownFilePath, +async function getActionsFromMdxFile( + tutorialFilePath: MdxFilePath, tutorialApp: TutorialApp, ): Promise { const actions: Action[] = []; From 6390e921a248df49d6ee0b0c25893dc7c6cc78d3 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 29 Sep 2025 15:35:59 +0200 Subject: [PATCH 36/48] PR comments --- web/docs/tutorial/TutorialAction.tsx | 12 ++++---- web/tutorial-actions-executor/.gitignore | 2 +- web/tutorial-actions-executor/README.md | 29 +++++++------------ .../src/actions/actions.ts | 2 +- .../src/actions/index.ts | 11 ++++--- .../src/commands/list-actions/index.ts | 2 +- .../src/extract-actions/index.ts | 14 ++++----- web/tutorial-actions-executor/src/log.ts | 12 ++++---- .../src/tutorialApp.ts | 4 +-- 9 files changed, 40 insertions(+), 48 deletions(-) diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index 775550493b..1dce201a24 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -1,8 +1,8 @@ /* -`TutorialAction` component is related to the Tutorial Actions Executor which you can find in the `web/tutorial-actions-executor` folder. -It has two main purposes: -1. It provides metadata on how to execute tutorial actions programmatically. -2. It renders tutorial action names during development for easier debugging. +`TutorialAction` component is related to the Tutorial Actions Executor (TACTE) which you can find in the `web/tutorial-actions-executor` folder. + +Its main purpose is to provide metadata on how to execute tutorial actions programmatically (using TACTE) +Additionally, it renders tutorial action names during development for easier debugging. `TutorialAction` component is used in the `web/docs/tutorial/*.md` files to annotate specific tutorial actions. */ @@ -23,12 +23,12 @@ type ActionProps = export function TutorialAction({ id, action }: { id: string } & ActionProps) { return ( process.env.NODE_ENV !== "production" && ( - + ) ); } -function TutorialActionDebug({ +function TutorialActionDebugInfo({ id, action, }: { diff --git a/web/tutorial-actions-executor/.gitignore b/web/tutorial-actions-executor/.gitignore index d7e24f8462..f7ed446125 100644 --- a/web/tutorial-actions-executor/.gitignore +++ b/web/tutorial-actions-executor/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.generated-apps/ +.result/ diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index 2af98f34a4..317a3a737f 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -32,13 +32,12 @@ This command: - Creates a Git commit for each action - Results in a fully functional application -Notes: - -- Stops on the first error to keep the repository in a consistent state. +If one of the patches fails to apply, the CLI will ask you to recreate the patch manually +and try to continue. ### 2. Edit Action (`npm run edit-action`) -Allows you to modify a specific tutorial action and automatically update all subsequent actions. +Allows you to modify a specific patch action and automatically update all subsequent patch actions. ```bash # Non-interactive (direct by ID): @@ -56,11 +55,11 @@ npm run edit-action -- --wasp-cli-command wasp This command: -- Generates the app (unless `--skip-generating-app`) -- Creates a branch from the action's commit -- Captures your edits as a new patch -- Rebases the fixes onto the main branch (prompts to resolve conflicts if needed) -- Regenerates patch files to reflect the updated history +- Applies all actions before the target action +- Moves all the changes from the target action to the staging area +- Allows you to edit the code in your editor +- Updates the patch based on your changes +- Reapplies all subsequent actions, allowing you to resolve any conflicts ### 3. List Actions (`npm run list-actions`) @@ -75,16 +74,8 @@ You will see actions grouped by tutorial filename, including the action `id` and ### Patch File Management - Patch files are stored in the `./docs/tutorial/patches/` directory -- Files are named based on the source file and the action id -- Each file contains the Git diff for that specific action -- When an action is edited, all patch files are automatically regenerated from the current commit history - -### Database Migration Handling - -For actions that involve database changes: - -- Uses `wasp db migrate-dev --name ` instead of applying a patch -- Migration files are committed with the action ID as the commit message +- Files are named using the source file and the action id +- Each patch file contains a Git diff for that specific action ### Tutorial File Format diff --git a/web/tutorial-actions-executor/src/actions/actions.ts b/web/tutorial-actions-executor/src/actions/actions.ts index 24e7c9dc7a..9928416231 100644 --- a/web/tutorial-actions-executor/src/actions/actions.ts +++ b/web/tutorial-actions-executor/src/actions/actions.ts @@ -20,7 +20,7 @@ export interface MigrateDbAction extends BaseAction { export interface BaseAction { id: ActionId; - tutorialFilePath: MdxFilePath; + sourceTutorialFilePath: MdxFilePath; } export type ActionId = Branded; diff --git a/web/tutorial-actions-executor/src/actions/index.ts b/web/tutorial-actions-executor/src/actions/index.ts index 4b5ab368ab..16c22c666b 100644 --- a/web/tutorial-actions-executor/src/actions/index.ts +++ b/web/tutorial-actions-executor/src/actions/index.ts @@ -36,16 +36,19 @@ export function createApplyPatchAction( ): ApplyPatchAction { const patchFilename = getPatchFilename( commonData.id, - commonData.tutorialFilePath, + commonData.sourceTutorialFilePath, ); return { ...commonData, kind: "APPLY_PATCH", - displayName: `${basename(commonData.tutorialFilePath)} / ${commonData.id}`, + displayName: `${basename(commonData.sourceTutorialFilePath)} / ${commonData.id}`, patchFilePath: path.resolve(patchesDirPath, patchFilename) as PatchFilePath, }; } -function getPatchFilename(id: ActionId, tutorialFilePath: MdxFilePath): string { - return `${getFileNameWithoutExtension(tutorialFilePath)}__${id}.patch`; +function getPatchFilename( + id: ActionId, + sourceTutorialFilePath: MdxFilePath, +): string { + return `${getFileNameWithoutExtension(sourceTutorialFilePath)}__${id}.patch`; } diff --git a/web/tutorial-actions-executor/src/commands/list-actions/index.ts b/web/tutorial-actions-executor/src/commands/list-actions/index.ts index 80961261f4..a92983edb6 100644 --- a/web/tutorial-actions-executor/src/commands/list-actions/index.ts +++ b/web/tutorial-actions-executor/src/commands/list-actions/index.ts @@ -22,7 +22,7 @@ function groupActionsBySourceFile(actions: Action[]): ActionsGroupedByFile { const groupedActions = new Map(); for (const action of actions) { - const filename = basename(action.tutorialFilePath); + const filename = basename(action.sourceTutorialFilePath); const existingActions = groupedActions.get(filename) ?? []; groupedActions.set(filename, [...existingActions, action]); } diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 213b389a05..6b6b3822c8 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -56,11 +56,11 @@ async function getTutorialFilePaths( } async function getActionsFromMdxFile( - tutorialFilePath: MdxFilePath, + sourceTutorialFilePath: MdxFilePath, tutorialApp: TutorialApp, ): Promise { const actions: Action[] = []; - const fileContent = await fs.readFile(path.resolve(tutorialFilePath)); + const fileContent = await fs.readFile(path.resolve(sourceTutorialFilePath)); const ast = fromMarkdown(fileContent, { extensions: [mdxJsx({ acorn, addResult: true })], @@ -83,25 +83,25 @@ async function getActionsFromMdxFile( if (!actionId) { throw new Error( - `TutorialAction component requires the 'id' attribute. File: ${tutorialFilePath}`, + `TutorialAction component requires the 'id' attribute. File: ${sourceTutorialFilePath}`, ); } if (!actionName) { throw new Error( - `TutorialAction component requires the 'action' attribute. File: ${tutorialFilePath}`, + `TutorialAction component requires the 'action' attribute. File: ${sourceTutorialFilePath}`, ); } const commonData: BaseAction = { id: actionId, - tutorialFilePath, + sourceTutorialFilePath, }; switch (actionName) { case "INIT_APP": if (waspStarterTemplateName === null) { throw new Error( - `TutorialAction with action 'INIT_APP' requires the 'starterTemplateName' attribute. File: ${tutorialFilePath}`, + `TutorialAction with action 'INIT_APP' requires the 'starterTemplateName' attribute. File: ${sourceTutorialFilePath}`, ); } actions.push(createInitAppAction(commonData, waspStarterTemplateName)); @@ -120,7 +120,7 @@ async function getActionsFromMdxFile( default: assertUnreachable( actionName, - `Unknown action '${actionName}' in TutorialAction component. File: ${tutorialFilePath}`, + `Unknown action '${actionName}' in TutorialAction component. File: ${sourceTutorialFilePath}`, ); } }); diff --git a/web/tutorial-actions-executor/src/log.ts b/web/tutorial-actions-executor/src/log.ts index 0407b0ae75..da1e86379e 100644 --- a/web/tutorial-actions-executor/src/log.ts +++ b/web/tutorial-actions-executor/src/log.ts @@ -1,13 +1,13 @@ import { chalk } from "zx"; -const colors = { - info: chalk.blue, - success: chalk.green, - error: chalk.red, +const levels = { + info: { color: chalk.blue }, + success: { color: chalk.green }, + error: { color: chalk.red }, }; -export function log(level: keyof typeof colors, message: string) { +export function log(level: keyof typeof levels, message: string) { console.log( - colors[level](`[${level.toUpperCase()}] ${chalk.reset(message)}`), + levels[level].color(`[${level.toUpperCase()}] ${chalk.reset(message)}`), ); } diff --git a/web/tutorial-actions-executor/src/tutorialApp.ts b/web/tutorial-actions-executor/src/tutorialApp.ts index cfaed54867..89e7b58ac6 100644 --- a/web/tutorial-actions-executor/src/tutorialApp.ts +++ b/web/tutorial-actions-executor/src/tutorialApp.ts @@ -9,9 +9,7 @@ export type TutorialDirPath = Branded; export type PatchesDirPath = Branded; const tutorialAppName = "TodoApp" as AppName; -const tutorialAppParentDirPath = path.resolve( - "./.generated-apps", -) as AppParentDirPath; +const tutorialAppParentDirPath = path.resolve("./.result") as AppParentDirPath; const tutorialAppDirPath = path.resolve( tutorialAppParentDirPath, tutorialAppName, From 939c0664f2bc443f16f9aaa103f17dd0f97957e9 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 29 Sep 2025 16:31:15 +0200 Subject: [PATCH 37/48] Add unit tests --- .../package-lock.json | 996 +++++++++++++++++- web/tutorial-actions-executor/package.json | 6 +- .../src/commands/list-actions/index.ts | 4 +- .../src/extract-actions/index.ts | 43 +- .../tests/actions/git.fixtures.ts | 56 + .../tests/actions/git.test.ts | 33 + .../tests/actions/index.test.ts | 68 ++ .../tests/commands/list-actions.test.ts | 41 + .../tests/extract-actions/index.test.ts | 58 + .../tests/files.test.ts | 21 + .../vitest.config.ts | 8 + 11 files changed, 1317 insertions(+), 17 deletions(-) create mode 100644 web/tutorial-actions-executor/tests/actions/git.fixtures.ts create mode 100644 web/tutorial-actions-executor/tests/actions/git.test.ts create mode 100644 web/tutorial-actions-executor/tests/actions/index.test.ts create mode 100644 web/tutorial-actions-executor/tests/commands/list-actions.test.ts create mode 100644 web/tutorial-actions-executor/tests/extract-actions/index.test.ts create mode 100644 web/tutorial-actions-executor/tests/files.test.ts create mode 100644 web/tutorial-actions-executor/vitest.config.ts diff --git a/web/tutorial-actions-executor/package-lock.json b/web/tutorial-actions-executor/package-lock.json index 94a2482ae1..6c7d639304 100644 --- a/web/tutorial-actions-executor/package-lock.json +++ b/web/tutorial-actions-executor/package-lock.json @@ -28,7 +28,8 @@ }, "devDependencies": { "@types/fs-extra": "^11.0.4", - "@types/mdast": "^4.0.4" + "@types/mdast": "^4.0.4", + "vitest": "^3.2.4" } }, "node_modules/@commander-js/extra-typings": { @@ -760,6 +761,331 @@ } } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", + "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", + "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", + "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", + "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", + "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", + "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", + "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", + "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", + "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", + "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", + "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", + "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", + "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", + "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", + "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", + "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", + "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", + "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", + "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", + "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", + "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", + "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -769,6 +1095,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -845,6 +1178,121 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -896,6 +1344,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -906,6 +1374,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -952,6 +1437,16 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -1032,6 +1527,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1060,6 +1565,13 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", @@ -1137,6 +1649,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -1164,6 +1696,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -1263,6 +1813,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -1273,6 +1830,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -1987,6 +2561,25 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -2027,6 +2620,72 @@ "integrity": "sha512-oh3giwKzsPlOhekiDDyd/pfFKn04IZoTjEThquhfKigwiUHymiP/Tp6AN5nGIwXQdWuBTQvz9AaRdN5TBsJ8MA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/remark-comment": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/remark-comment/-/remark-comment-1.0.0.tgz", @@ -2119,12 +2778,61 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rollup": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", + "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.3", + "@rollup/rollup-android-arm64": "4.52.3", + "@rollup/rollup-darwin-arm64": "4.52.3", + "@rollup/rollup-darwin-x64": "4.52.3", + "@rollup/rollup-freebsd-arm64": "4.52.3", + "@rollup/rollup-freebsd-x64": "4.52.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", + "@rollup/rollup-linux-arm-musleabihf": "4.52.3", + "@rollup/rollup-linux-arm64-gnu": "4.52.3", + "@rollup/rollup-linux-arm64-musl": "4.52.3", + "@rollup/rollup-linux-loong64-gnu": "4.52.3", + "@rollup/rollup-linux-ppc64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-musl": "4.52.3", + "@rollup/rollup-linux-s390x-gnu": "4.52.3", + "@rollup/rollup-linux-x64-gnu": "4.52.3", + "@rollup/rollup-linux-x64-musl": "4.52.3", + "@rollup/rollup-openharmony-arm64": "4.52.3", + "@rollup/rollup-win32-arm64-msvc": "4.52.3", + "@rollup/rollup-win32-ia32-msvc": "4.52.3", + "@rollup/rollup-win32-x64-gnu": "4.52.3", + "@rollup/rollup-win32-x64-msvc": "4.52.3", + "fsevents": "~2.3.2" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2137,6 +2845,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2177,6 +2909,80 @@ "node": ">=8" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -2309,6 +3115,194 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vite": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", + "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/web/tutorial-actions-executor/package.json b/web/tutorial-actions-executor/package.json index 9b50063f88..09d49fe807 100644 --- a/web/tutorial-actions-executor/package.json +++ b/web/tutorial-actions-executor/package.json @@ -6,7 +6,8 @@ "scripts": { "edit-action": "tsx ./src/index.ts edit-action", "generate-app": "tsx ./src/index.ts generate-app", - "list-actions": "tsx ./src/index.ts list-actions" + "list-actions": "tsx ./src/index.ts list-actions", + "test": "vitest" }, "dependencies": { "@commander-js/extra-typings": "^14.0.0", @@ -28,6 +29,7 @@ }, "devDependencies": { "@types/fs-extra": "^11.0.4", - "@types/mdast": "^4.0.4" + "@types/mdast": "^4.0.4", + "vitest": "^3.2.4" } } diff --git a/web/tutorial-actions-executor/src/commands/list-actions/index.ts b/web/tutorial-actions-executor/src/commands/list-actions/index.ts index a92983edb6..85a23d7e2f 100644 --- a/web/tutorial-actions-executor/src/commands/list-actions/index.ts +++ b/web/tutorial-actions-executor/src/commands/list-actions/index.ts @@ -18,7 +18,9 @@ export const listActionsCommand = new Command("list-actions") displayGroupedActions(actionsGroupedByFile); }); -function groupActionsBySourceFile(actions: Action[]): ActionsGroupedByFile { +export function groupActionsBySourceFile( + actions: Action[], +): ActionsGroupedByFile { const groupedActions = new Map(); for (const action of actions) { diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 6b6b3822c8..3567a3f0f5 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -41,26 +41,43 @@ async function getTutorialFilePaths( tutorialDir: TutorialDirPath, ): Promise { const files = await fs.readdir(tutorialDir); - return ( - files - .filter((file) => file.endsWith(".md")) - // Tutorial files are named "01-something.md" - // and we want to sort them by the number prefix - .sort((a, b) => { - const aNumber = parseInt(a.split("-")[0]!, 10); - const bNumber = parseInt(b.split("-")[0]!, 10); - return aNumber - bNumber; - }) - .map((file) => path.resolve(tutorialDir, file) as MdxFilePath) + return sortTutorialFileNames(filterTutorialFileNames(files)).map( + (file) => path.resolve(tutorialDir, file) as MdxFilePath, ); } +export function filterTutorialFileNames(filePaths: string[]): string[] { + return filePaths.filter((file) => file.endsWith(".md")); +} + +// Tutorial files are named "01-something.md" +// and we want to sort them by the number prefix +export function sortTutorialFileNames(filePaths: string[]): string[] { + return filePaths.sort((a, b) => { + const aNumber = parseInt(a.split("-")[0]!, 10); + const bNumber = parseInt(b.split("-")[0]!, 10); + return aNumber - bNumber; + }); +} + async function getActionsFromMdxFile( sourceTutorialFilePath: MdxFilePath, tutorialApp: TutorialApp, ): Promise { - const actions: Action[] = []; const fileContent = await fs.readFile(path.resolve(sourceTutorialFilePath)); + return getActionsFromMdxContent( + sourceTutorialFilePath, + fileContent, + tutorialApp, + ); +} + +export async function getActionsFromMdxContent( + sourceTutorialFilePath: MdxFilePath, + fileContent: Buffer, + tutorialApp: TutorialApp, +): Promise { + const actions: Action[] = []; const ast = fromMarkdown(fileContent, { extensions: [mdxJsx({ acorn, addResult: true })], @@ -128,7 +145,7 @@ async function getActionsFromMdxFile( return actions; } -function getAttributeValue( +export function getAttributeValue( node: MdxJsxFlowElement, attributeName: string, ): string | null { diff --git a/web/tutorial-actions-executor/tests/actions/git.fixtures.ts b/web/tutorial-actions-executor/tests/actions/git.fixtures.ts new file mode 100644 index 0000000000..417c6ac1ad --- /dev/null +++ b/web/tutorial-actions-executor/tests/actions/git.fixtures.ts @@ -0,0 +1,56 @@ +export const singleFilePatch = `diff --git a/test.js b/test.js +index 1234567..abcdefg 100644 +--- a/test.js ++++ b/test.js +@@ -1,3 +1,4 @@ + console.log('hello'); ++console.log('world'); + export const x = 1; + export const y = 2;`; + +export const multiFilePatch = `diff --git a/file1.js b/file1.js +index 1234567..abcdefg 100644 +--- a/file1.js ++++ b/file1.js +@@ -1,2 +1,3 @@ + export const a = 1; ++export const b = 2; + export { a }; +diff --git a/file2.js b/file2.js +index 2345678..bcdefgh 100644 +--- a/file2.js ++++ b/file2.js +@@ -1,2 +1,3 @@ + export const c = 3; ++export const d = 4; + export { c };`; + +export const deletionPatch = `diff --git a/deleted-file.js b/deleted-file.js +deleted file mode 100644 +index 1234567..0000000 +--- a/deleted-file.js ++++ /dev/null +@@ -1,3 +0,0 @@ +-console.log('deleted'); +-export const x = 1; +-export { x };`; + +export const additionPatch = `diff --git a/new-file.js b/new-file.js +new file mode 100644 +index 0000000..1234567 +--- /dev/null ++++ b/new-file.js +@@ -0,0 +1,3 @@ ++console.log('new file'); ++export const y = 2; ++export { y };`; + +export const binaryPatch = `diff --git a/image.png b/image.png +index 1234567..abcdefg 100644 +Binary files a/image.png and b/image.png differ`; + +export const noFilesPatch = `commit abc123 +Author: Test User +Date: Mon Jan 1 00:00:00 2023 +0000 + + Some commit message`; diff --git a/web/tutorial-actions-executor/tests/actions/git.test.ts b/web/tutorial-actions-executor/tests/actions/git.test.ts new file mode 100644 index 0000000000..cf08a312b9 --- /dev/null +++ b/web/tutorial-actions-executor/tests/actions/git.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { assertValidPatch } from "../../src/actions/git"; +import { + additionPatch, + binaryPatch, + deletionPatch, + multiFilePatch, + noFilesPatch, + singleFilePatch, +} from "./git.fixtures"; + +describe("assertValidPatch", () => { + expectValidPatchNotToThrow(singleFilePatch); + expectValidPatchNotToThrow(multiFilePatch); + expectValidPatchNotToThrow(deletionPatch); + expectValidPatchNotToThrow(additionPatch); + expectValidPatchNotToThrow(binaryPatch); + + expectInvalidPatchToThrow(noFilesPatch, "Invalid patch: no changes found"); +}); + +function expectValidPatchNotToThrow(patch: string) { + it("should not throw for valid patch", async () => { + await expect(assertValidPatch(patch)).resolves.not.toThrow(); + }); +} + +function expectInvalidPatchToThrow(patch: string, errorMessage: string) { + it(`should throw for invalid patch: ${errorMessage}`, async () => { + await expect(assertValidPatch(patch)).rejects.toThrow(errorMessage); + }); +} diff --git a/web/tutorial-actions-executor/tests/actions/index.test.ts b/web/tutorial-actions-executor/tests/actions/index.test.ts new file mode 100644 index 0000000000..bec7dadbf2 --- /dev/null +++ b/web/tutorial-actions-executor/tests/actions/index.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import type { + ActionId, + BaseAction, + MdxFilePath, +} from "../../src/actions/actions"; +import { + createApplyPatchAction, + createInitAppAction, + createMigrateDbAction, +} from "../../src/actions/index"; +import type { PatchesDirPath } from "../../src/tutorialApp"; + +describe("createInitAppAction", () => { + it("should create InitAppAction with correct properties", () => { + const baseAction: BaseAction = { + id: "test-init" as ActionId, + sourceTutorialFilePath: "/docs/01-intro.md" as MdxFilePath, + }; + + const result = createInitAppAction(baseAction, "basic"); + + expect(result).toEqual({ + id: "test-init", + sourceTutorialFilePath: "/docs/01-intro.md", + kind: "INIT_APP", + waspStarterTemplateName: "basic", + }); + }); +}); + +describe("createMigrateDbAction", () => { + it("should create MigrateDbAction with correct properties", () => { + const baseAction: BaseAction = { + id: "db-migrate" as ActionId, + sourceTutorialFilePath: "/docs/02-database.md" as MdxFilePath, + }; + + const result = createMigrateDbAction(baseAction); + + expect(result).toEqual({ + id: "db-migrate", + sourceTutorialFilePath: "/docs/02-database.md", + kind: "MIGRATE_DB", + }); + }); +}); + +describe("createApplyPatchAction", () => { + it("should create ApplyPatchAction with correct properties", () => { + const baseAction: BaseAction = { + id: "add-auth" as ActionId, + sourceTutorialFilePath: "/docs/03-auth.md" as MdxFilePath, + }; + const patchesDirPath = "/patches" as PatchesDirPath; + + const result = createApplyPatchAction(baseAction, patchesDirPath); + + expect(result).toEqual({ + id: "add-auth", + sourceTutorialFilePath: "/docs/03-auth.md", + kind: "APPLY_PATCH", + displayName: "03-auth.md / add-auth", + patchFilePath: "/patches/03-auth__add-auth.patch", + }); + }); +}); diff --git a/web/tutorial-actions-executor/tests/commands/list-actions.test.ts b/web/tutorial-actions-executor/tests/commands/list-actions.test.ts new file mode 100644 index 0000000000..9b71eee3f1 --- /dev/null +++ b/web/tutorial-actions-executor/tests/commands/list-actions.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import type { Action, ActionId, MdxFilePath } from "../../src/actions/actions"; +import { groupActionsBySourceFile } from "../../src/commands/list-actions/index"; + +describe("groupActionsBySourceFile", () => { + it("should group actions by source file basename", () => { + const actions: Action[] = [ + { + id: "action1" as ActionId, + sourceTutorialFilePath: "/path/to/01-intro.md" as MdxFilePath, + kind: "INIT_APP", + waspStarterTemplateName: "basic", + }, + { + id: "action2" as ActionId, + sourceTutorialFilePath: "/path/to/01-intro.md" as MdxFilePath, + kind: "APPLY_PATCH", + displayName: "Add auth", + patchFilePath: "/patches/auth.patch" as any, + }, + { + id: "action3" as ActionId, + sourceTutorialFilePath: "/path/to/02-setup.md" as MdxFilePath, + kind: "MIGRATE_DB", + }, + ]; + + const result = groupActionsBySourceFile(actions); + + expect(result.size).toBe(2); + expect(result.get("01-intro.md")).toEqual([actions[0], actions[1]]); + expect(result.get("02-setup.md")).toEqual([actions[2]]); + }); + + it("should handle empty actions array", () => { + const actions: Action[] = []; + const result = groupActionsBySourceFile(actions); + expect(result.size).toBe(0); + }); +}); diff --git a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts new file mode 100644 index 0000000000..013151d671 --- /dev/null +++ b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts @@ -0,0 +1,58 @@ +import type { MdxJsxAttribute, MdxJsxFlowElement } from "mdast-util-mdx-jsx"; +import { describe, expect, it } from "vitest"; + +import { + filterTutorialFileNames, + getAttributeValue, + sortTutorialFileNames, +} from "../../src/extract-actions/index"; + +describe("getAttributeValue", () => { + it("should return correct value when attributes exist", () => { + const mockNode = { + attributes: [ + { + type: "mdxJsxAttribute", + name: "action", + value: "APPLY_PATCH", + }, + { + type: "mdxJsxAttribute", + name: "id", + value: "test-action", + }, + ], + } as MdxJsxFlowElement; + + expect(getAttributeValue(mockNode, "id")).toBe("test-action"); + expect(getAttributeValue(mockNode, "action")).toBe("APPLY_PATCH"); + }); + + it("should return null when attribute does not exist", () => { + const mockNode = { + attributes: [] as MdxJsxAttribute[], + } as MdxJsxFlowElement; + + expect(getAttributeValue(mockNode, "id")).toBe(null); + }); +}); + +describe("filterTutorialFileNames", () => { + it("should filter files ending with .md", () => { + const files = ["01-intro.md", "02-setup.md", "README.txt", "package.json"]; + + const result = filterTutorialFileNames(files); + + expect(result).toEqual(["01-intro.md", "02-setup.md"]); + }); +}); + +describe("sortTutorialFileNames", () => { + it("should sort files by numeric prefix", () => { + const files = ["03-advanced.md", "01-intro.md", "02-setup.md"]; + + const result = sortTutorialFileNames(files); + + expect(result).toEqual(["01-intro.md", "02-setup.md", "03-advanced.md"]); + }); +}); diff --git a/web/tutorial-actions-executor/tests/files.test.ts b/web/tutorial-actions-executor/tests/files.test.ts new file mode 100644 index 0000000000..e82df04d60 --- /dev/null +++ b/web/tutorial-actions-executor/tests/files.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { getFileNameWithoutExtension } from "../src/files"; + +describe("getFileNameWithoutExtension", () => { + testGettingFileNameWithoutExtension("test.txt", "test"); + testGettingFileNameWithoutExtension("test.config.js", "test.config"); + testGettingFileNameWithoutExtension("test", "test"); + testGettingFileNameWithoutExtension("/path/to/test.txt", "test"); + testGettingFileNameWithoutExtension("./src/test.ts", "test"); + testGettingFileNameWithoutExtension("", ""); +}); + +function testGettingFileNameWithoutExtension( + filePath: string, + expectedFileName: string, +) { + it(`should return "${expectedFileName}" for "${filePath}"`, () => { + expect(getFileNameWithoutExtension(filePath)).toBe(expectedFileName); + }); +} diff --git a/web/tutorial-actions-executor/vitest.config.ts b/web/tutorial-actions-executor/vitest.config.ts new file mode 100644 index 0000000000..ba29bd1c7a --- /dev/null +++ b/web/tutorial-actions-executor/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + }, +}); From aea3c6da8b3e48c61bd9a614e384b293d086f3f1 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 6 Oct 2025 13:30:23 +0200 Subject: [PATCH 38/48] Update code block language --- web/tutorial-actions-executor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index 317a3a737f..fdfef15447 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -81,7 +81,7 @@ You will see actions grouped by tutorial filename, including the action `id` and Tutorial actions are defined in MDX files using JSX components: -````markdown +````mdx # Step 4: Create Task Entity From 7c03e0c66bdb57a82947301af6ec2b1f778a9826 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 6 Oct 2025 13:45:07 +0200 Subject: [PATCH 39/48] Cleanup README --- web/tutorial-actions-executor/README.md | 54 +++++++++++-------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index fdfef15447..03bd13e85d 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -4,9 +4,9 @@ Wasp docs have a tutorial that walks users through building a complete Wasp application step-by-step. -Next to the text that explains each step, we added the `` components that define actions -that define machine executable steps, like "create a new Wasp app", "add authentication", "create a Task entity", etc. -This CLI tool, reads those tutorial files, extracts the actions, and executes them in sequence +Next to the text that explains each step, we added `` components that define machine-executable steps, +like "create a new Wasp app", "add authentication", "create a Task entity", etc. +This CLI tool reads those tutorial files, extracts the actions, and executes them in sequence to create a fully functional Wasp application. ## Commands @@ -25,19 +25,18 @@ npm run generate-app -- --wasp-cli-command wasp This command: -- Initializes a new Wasp application - Reads all tutorial files (numbered like `01-setup.md`, `02-auth.md`, etc.) - Extracts `` components from the file -- Applies each action's patches or database migrations in order -- Creates a Git commit for each action +- Applies each action in sequence (e.g. initialize app, apply patches, migrate DB) - Results in a fully functional application -If one of the patches fails to apply, the CLI will ask you to recreate the patch manually -and try to continue. +One of the actions is to apply a Git patch that modifies a source file. If applying +a patch fails due to conflicts, `generate-app` command pauses and allows you +to resolve the conflicts manually. ### 2. Edit Action (`npm run edit-action`) -Allows you to modify a specific patch action and automatically update all subsequent patch actions. +Allows you to modify a specific patch action and automatically reapplies all subsequent actions. ```bash # Non-interactive (direct by ID): @@ -55,11 +54,11 @@ npm run edit-action -- --wasp-cli-command wasp This command: -- Applies all actions before the target action -- Moves all the changes from the target action to the staging area +- Executes all actions before the target action +- Moves all the changes from the target action to the Git staging area - Allows you to edit the code in your editor - Updates the patch based on your changes -- Reapplies all subsequent actions, allowing you to resolve any conflicts +- Reapplies all subsequent actions ### 3. List Actions (`npm run list-actions`) @@ -69,12 +68,12 @@ Displays all available tutorial actions organized by source file. npm run list-actions ``` -You will see actions grouped by tutorial filename, including the action `id` and its `kind`. +Shows actions grouped by tutorial file, including each action's `id` and `kind`. ### Patch File Management -- Patch files are stored in the `./docs/tutorial/patches/` directory -- Files are named using the source file and the action id +- Patch files are stored in `./docs/tutorial/patches/` +- Files are named using the source file and action ID - Each patch file contains a Git diff for that specific action ### Tutorial File Format @@ -100,19 +99,14 @@ The tool extracts these components and uses: - `id`: Unique identifier for the action (becomes commit message) - `action`: Type of action (`INIT_APP`, `APPLY_PATCH`, `MIGRATE_DB`) -This Git-based approach ensures that: +## How It Works: Git-Based Workflow -- **Changes can be made to any action** without breaking subsequent actions -- **Conflicts are automatically handled** by Git's rebasing mechanism - -## Extra Info: How It Works on Git Level, Patches, and Rebasing - -The tool uses a Git-based workflow to manage tutorial actions: +This tool uses a Git-based workflow to manage tutorial actions: ### Executing Tutorial Actions 1. **Initial Setup**: Creates a Git repository with an initial commit -2. **Action Execution**: Each action is executed and committed as a separate Git commit +2. **Action Execution**: Each tutorial action is executed and committed as a separate Git commit, with the action ID as the commit message ### Action Editing Process @@ -138,23 +132,23 @@ git switch --force-create fixes #### Phase 2: User Editing ```bash -# Move the action 4 commit changes to staging area +# Move action 4's changes to staging area git reset --soft HEAD~1 -# User makes their edits in the editor -# User confirms they're done +# User edits the code in their editor +# User confirms when done ``` #### Phase 3: Patch Creation and Application ```bash -# Commit all current changes and generate a new patch +# Commit changes and generate a new patch git add . git commit -m "temporary-commit" git show HEAD --format= > new-patch.patch git reset --hard HEAD~1 -# Apply the new patch and commit with original action ID +# Apply the new patch and commit with the original action ID git apply new-patch.patch git commit -m "action-4-create-task-entity" ``` @@ -169,10 +163,10 @@ git rebase fixes # This integrates the fixed action 4 # If conflicts occur, user resolves them like any other Git conflict ``` -#### Phase 5: Patch File Updates +#### Phase 5: Regenerate Patch Files ```bash -# Regenerate all patch files from the updated commits +# Regenerate patch files from the updated commits # For each action after the edited one: git show action-commit-sha --format= > patches/action-N.patch ``` From 9ecdbd630a1a4147321144b44d0f113c1490b345 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 6 Oct 2025 14:49:20 +0200 Subject: [PATCH 40/48] Wrap all steps with component --- web/docs/tutorial/01-create.md | 3 +- web/docs/tutorial/03-pages.md | 3 +- web/docs/tutorial/04-entities.md | 3 +- web/docs/tutorial/05-queries.md | 10 ++- web/docs/tutorial/06-actions.md | 24 ++++-- web/docs/tutorial/07-auth.md | 33 ++++++--- web/docs/tutorial/TutorialAction.tsx | 105 ++++++++++++++++++--------- 7 files changed, 122 insertions(+), 59 deletions(-) diff --git a/web/docs/tutorial/01-create.md b/web/docs/tutorial/01-create.md index a2db0770d4..2a37a27573 100644 --- a/web/docs/tutorial/01-create.md +++ b/web/docs/tutorial/01-create.md @@ -25,11 +25,12 @@ You can find the complete code of the app we're about to build [here](https://gi To setup a new Wasp project, run the following command in your terminal: - + ```sh wasp new TodoApp -t minimal ``` + diff --git a/web/docs/tutorial/03-pages.md b/web/docs/tutorial/03-pages.md index 87151073fe..b13980cae7 100644 --- a/web/docs/tutorial/03-pages.md +++ b/web/docs/tutorial/03-pages.md @@ -97,7 +97,7 @@ Now that you've seen how Wasp deals with Routes and Pages, it's finally time to Start by cleaning up the starter project and removing unnecessary code and files. - + First, remove most of the code from the `MainPage` component: @@ -133,6 +133,7 @@ page MainPage { component: import { MainPage } from "@src/MainPage" } ``` + Excellent work! diff --git a/web/docs/tutorial/04-entities.md b/web/docs/tutorial/04-entities.md index f7b163f13f..ab75b28c36 100644 --- a/web/docs/tutorial/04-entities.md +++ b/web/docs/tutorial/04-entities.md @@ -11,7 +11,7 @@ Wasp uses Prisma to talk to the database, and you define Entities by defining Pr Since our Todo app is all about tasks, we'll define a Task entity by adding a Task model in the `schema.prisma` file: - + ```prisma title="schema.prisma" // ... @@ -22,6 +22,7 @@ model Task { isDone Boolean @default(false) } ``` + :::note Read more about how Wasp Entities work in the [Entities](../data-model/entities.md) section or how Wasp uses the `schema.prisma` file in the [Prisma Schema File](../data-model/prisma-file.md) section. diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index 2901cddee8..8c111df5c3 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -25,6 +25,7 @@ We'll create a new Query called `getTasks`. We'll need to declare the Query in t We need to add a **query** declaration to `main.wasp` so that Wasp knows it exists: + ```wasp title="main.wasp" @@ -44,7 +45,6 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis - ```wasp title="main.wasp" // ... @@ -65,6 +65,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis + ### Implementing a Query @@ -76,7 +77,7 @@ We need to add a **query** declaration to `main.wasp` so that Wasp knows it exis Next, create a new file called `src/queries.ts` and define the TypeScript function we've just imported in our `query` declaration: - + ```ts title="src/queries.ts" auto-js import type { Task } from "wasp/entities"; @@ -88,7 +89,7 @@ export const getTasks: GetTasks = async (args, context) => { }); }; ``` - + Wasp automatically generates the types `GetTasks` and `Task` based on the contents of `main.wasp`: @@ -122,7 +123,7 @@ While we implement Queries on the server, Wasp generates client-side functions t This makes it easy for us to use the `getTasks` Query we just created in our React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { Task } from "wasp/entities"; @@ -167,6 +168,7 @@ const TasksList = ({ tasks }: { tasks: Task[] }) => { }; // highlight-end ``` + Most of this code is regular React, the only exception being the twothree special `wasp` imports: diff --git a/web/docs/tutorial/06-actions.md b/web/docs/tutorial/06-actions.md index 837d66cd90..a00d9b9935 100644 --- a/web/docs/tutorial/06-actions.md +++ b/web/docs/tutorial/06-actions.md @@ -23,7 +23,7 @@ Creating an Action is very similar to creating a Query. We must first declare the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -33,12 +33,13 @@ action createTask { entities: [Task] } ``` + ### Implementing an Action Let's now define a JavaScriptTypeScript function for our `createTask` Action: - + ```ts title="src/actions.ts" auto-js import type { Task } from "wasp/entities"; @@ -55,6 +56,7 @@ export const createTask: CreateTask = async ( }); }; ``` + Once again, we've annotated the Action with the `CreateTask` and `Task` types generated by Wasp. Just like with queries, defining the types on the implementation makes them available on the frontend, giving us **full-stack type safety**. @@ -68,7 +70,7 @@ We put the function in a new file `src/actions.{js,ts}`, but we could have put i Start by defining a form for creating new tasks. - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from "react"; @@ -105,6 +107,7 @@ const NewTaskForm = () => { }; // highlight-end ``` + Unlike Queries, you can call Actions directly (without wrapping them in a hook) because they don't need reactivity. The rest is just regular React code. @@ -114,7 +117,7 @@ Unlike Queries, you can call Actions directly (without wrapping them in a hook) All that's left now is adding this form to the page component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent } from "react"; @@ -137,6 +140,7 @@ const MainPage = () => { // ... TaskList, TaskView, NewTaskForm ... ``` + Great work! @@ -171,7 +175,7 @@ Since we've already created one task together, try to create this one yourself. Declaring the Action in `main.wasp`: - + ```wasp title="main.wasp" // ... @@ -181,11 +185,12 @@ action updateTask { entities: [Task] } ``` - - + Implementing the Action on the server: + + ```ts title="src/actions.ts" auto-js import type { CreateTask, UpdateTask } from "wasp/server/operations"; @@ -206,11 +211,13 @@ export const updateTask: UpdateTask = async ( }; ``` + + You can now call `updateTask` from the React component: - + ```tsx title="src/MainPage.tsx" auto-js import type { FormEvent, ChangeEvent } from "react"; @@ -255,6 +262,7 @@ const TaskView = ({ task }: { task: Task }) => { // ... TaskList, NewTaskForm ... ``` + Awesome! You can now mark this task as done. diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 98f141da81..f70cbb49ab 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -24,7 +24,7 @@ Since Wasp manages authentication, it will create [the auth related entities](.. You must only add the `User` Entity to keep track of who owns which tasks: - + ```prisma title="schema.prisma" // ... @@ -33,12 +33,13 @@ model User { id Int @id @default(autoincrement()) } ``` + ## Adding Auth to the Project Next, tell Wasp to use full-stack [authentication](../auth/overview): - + ```wasp title="main.wasp" app TodoApp { @@ -65,6 +66,7 @@ app TodoApp { // ... ``` + Don't forget to update the database schema by running: @@ -89,7 +91,7 @@ Wasp also supports authentication using [Google](../auth/social-auth/google), [G Wasp creates the login and signup forms for us, but we still need to define the pages to display those forms on. We'll start by declaring the pages in the Wasp file: - + ```wasp title="main.wasp" // ... @@ -104,12 +106,13 @@ page LoginPage { component: import { LoginPage } from "@src/LoginPage" } ``` + Great, Wasp now knows these pages exist! Here's the React code for the pages you've just imported: - + ```tsx title="src/LoginPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -127,10 +130,11 @@ export const LoginPage = () => { ); }; ``` + The signup page is very similar to the login page: - + ```tsx title="src/SignupPage.tsx" auto-js import { Link } from "react-router-dom"; @@ -148,6 +152,7 @@ export const SignupPage = () => { ); }; ``` + :::tip Type-safe links @@ -159,7 +164,7 @@ export const SignupPage = () => { We don't want users who are not logged in to access the main page, because they won't be able to create any tasks. So let's make the page private by requiring the user to be logged in: - + ```wasp title="main.wasp" // ... @@ -170,12 +175,13 @@ page MainPage { component: import { MainPage } from "@src/MainPage" } ``` + Now that auth is required for this page, unauthenticated users will be redirected to `/login`, as we specified with `app.auth.onAuthFailedRedirectTo`. Additionally, when `authRequired` is `true`, the page's React component will be provided a `user` object as prop. - + ```tsx title="src/MainPage.tsx" auto-js import type { AuthUser } from "wasp/auth"; @@ -186,6 +192,7 @@ export const MainPage = ({ user }: { user: AuthUser }) => { // ... }; ``` + Ok, time to test this out. Navigate to the main page (`/`) of the app. You'll get redirected to `/login`, where you'll be asked to authenticate. @@ -211,7 +218,7 @@ However, you will notice that if you try logging in as different users and creat First, let's define a one-to-many relation between users and tasks (check the [Prisma docs on relations](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/relations)): - + ```prisma title="schema.prisma" // ... @@ -232,6 +239,7 @@ model Task { userId Int? } ``` + As always, you must migrate the database after changing the Entities: @@ -252,7 +260,7 @@ Instead, we would do a data migration to take care of those tasks, even if it me Next, let's update the queries and actions to forbid access to non-authenticated users and to operate only on the currently logged-in user's tasks: - + ```ts title="src/queries.ts" auto-js import type { Task } from "wasp/entities"; @@ -273,8 +281,9 @@ export const getTasks: GetTasks = async (args, context) => { }); }; ``` + - + ```ts title="src/actions.ts" auto-js import type { Task } from "wasp/entities"; @@ -319,6 +328,7 @@ export const updateTask: UpdateTask< }); }; ``` + :::note Due to how Prisma works, we had to convert `update` to `updateMany` in `updateTask` action to be able to specify the user id in `where`. @@ -340,7 +350,7 @@ You will see that each user has their tasks, just as we specified in our code! Last, but not least, let's add the logout functionality: - + ```tsx title="src/MainPage.tsx" auto-js with-hole // ... @@ -359,6 +369,7 @@ const MainPage = () => { ); }; ``` + This is it, we have a working authentication system, and our Todo app is multi-user! diff --git a/web/docs/tutorial/TutorialAction.tsx b/web/docs/tutorial/TutorialAction.tsx index 1dce201a24..0b26631321 100644 --- a/web/docs/tutorial/TutorialAction.tsx +++ b/web/docs/tutorial/TutorialAction.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; /* `TutorialAction` component is related to the Tutorial Actions Executor (TACTE) which you can find in the `web/tutorial-actions-executor` folder. @@ -8,7 +9,9 @@ Additionally, it renders tutorial action names during development for easier deb */ // IMPORTANT: If you change actions here, make sure to also update the types in `web/tutorial-actions-executor/src/actions/actions.ts`. -type ActionProps = +type ActionProps = { + id: string; +} & ( | { action: "INIT_APP"; starterTemplateName: string; @@ -18,53 +21,90 @@ type ActionProps = } | { action: "MIGRATE_DB"; - }; + } +); -export function TutorialAction({ id, action }: { id: string } & ActionProps) { - return ( - process.env.NODE_ENV !== "production" && ( - - ) +export function TutorialAction(props: React.PropsWithChildren) { + const isDevelopment = process.env.NODE_ENV !== "production"; + + return isDevelopment ? ( + + ) : ( + props.children ); } function TutorialActionDebugInfo({ id, action, -}: { - id: string; - action: ActionProps["action"]; -}) { + children, +}: React.PropsWithChildren) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(id); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + return ( -
-
tutorial action: {action}
-
- {id} - { - navigator.clipboard.writeText(id); - }} - > - [copy] - +
+
+
tutorial action: {action}
+
+ id: {id} + +
+ {children &&
{children}
}
); } -const pillStyle: React.CSSProperties = { +const containerStyle: React.CSSProperties = { + marginBottom: "1.5rem", +}; + +const headerStyle: React.CSSProperties = { + display: "flex", + gap: "0.5rem", + marginBottom: "0.5rem", + alignItems: "center", +}; + +const idTextStyle: React.CSSProperties = { + marginRight: "0.25rem", +}; + +const copyButtonStyle: React.CSSProperties = { + fontSize: "0.65rem", + cursor: "pointer", + background: "rgba(255, 255, 255, 0.2)", + border: "1px solid rgba(255, 255, 255, 0.3)", borderRadius: "0.25rem", - paddingLeft: "0.5rem", - paddingRight: "0.5rem", - paddingTop: "0.25rem", - paddingBottom: "0.25rem", + padding: "0.125rem 0.375rem", + color: "white", + transition: "all 0.2s ease", +}; + +const childrenContainerStyle: React.CSSProperties = { + border: "1px dashed #ef4444", + padding: "1rem", + borderRadius: "0.5rem", +}; + +const pillStyle: React.CSSProperties = { + borderRadius: "0.375rem", + paddingLeft: "0.625rem", + paddingRight: "0.625rem", + paddingTop: "0.375rem", + paddingBottom: "0.375rem", fontSize: "0.75rem", - fontWeight: "bold", + fontWeight: "600", color: "white", + boxShadow: "0 1px 2px rgba(0, 0, 0, 0.1)", }; const tutorialActionPillStyle: React.CSSProperties = { @@ -77,5 +117,4 @@ const actionPillStyle: React.CSSProperties = { backgroundColor: "#ef4444", display: "flex", alignItems: "center", - gap: "0.25rem", }; From f78f56ab1b4e61d7618bf65f35c01b25d03e873e Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 10 Oct 2025 14:47:07 +0200 Subject: [PATCH 41/48] PR comments --- web/tutorial-actions-executor/README.md | 6 +- .../src/extract-actions/index.ts | 7 +- .../tests/extract-actions/index.test.ts | 218 ++++++++++++++++++ 3 files changed, 227 insertions(+), 4 deletions(-) diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index 03bd13e85d..6d8dc3e803 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -83,17 +83,19 @@ Tutorial actions are defined in MDX files using JSX components: ````mdx # Step 4: Create Task Entity - - In this action, we'll create the Task entity: + ```prisma model Task { id Int @id @default(autoincrement()) } ``` + ```` +The `` component should wrap the part of the tutorial text that it is associated with. + The tool extracts these components and uses: - `id`: Unique identifier for the action (becomes commit message) diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 3567a3f0f5..5336216fe9 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -50,8 +50,11 @@ export function filterTutorialFileNames(filePaths: string[]): string[] { return filePaths.filter((file) => file.endsWith(".md")); } -// Tutorial files are named "01-something.md" -// and we want to sort them by the number prefix +/** + * Tutorial files are named "01-something.md" and we want to sort them by the number prefix. + * @param filePaths + * @returns Sorted file paths + */ export function sortTutorialFileNames(filePaths: string[]): string[] { return filePaths.sort((a, b) => { const aNumber = parseInt(a.split("-")[0]!, 10); diff --git a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts index 013151d671..77166f29da 100644 --- a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts +++ b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts @@ -1,11 +1,20 @@ import type { MdxJsxAttribute, MdxJsxFlowElement } from "mdast-util-mdx-jsx"; import { describe, expect, it } from "vitest"; +import type { MdxFilePath } from "../../src/actions/actions"; import { filterTutorialFileNames, + getActionsFromMdxContent, getAttributeValue, sortTutorialFileNames, } from "../../src/extract-actions/index"; +import type { + AppDirPath, + AppName, + AppParentDirPath, + PatchesDirPath, + TutorialDirPath, +} from "../../src/tutorialApp"; describe("getAttributeValue", () => { it("should return correct value when attributes exist", () => { @@ -56,3 +65,212 @@ describe("sortTutorialFileNames", () => { expect(result).toEqual(["01-intro.md", "02-setup.md", "03-advanced.md"]); }); }); + +describe("getActionsFromMdxContent", () => { + const context = { + tutorialApp: { + name: "TestApp" as AppName, + parentDirPath: "/test/parent" as AppParentDirPath, + dirPath: "/test/parent/TestApp" as AppDirPath, + docsTutorialDirPath: "/test/tutorial" as TutorialDirPath, + docsTutorialPatchesPath: "/test/tutorial/patches" as PatchesDirPath, + }, + filePath: "/test/tutorial/01-intro.md" as MdxFilePath, + }; + + function itShouldExtractActions( + testName: string, + mdxContent: string, + expectedActions: unknown[], + ) { + it(`should extract actions: ${testName}`, async () => { + const actions = await getActionsFromMdxContent( + context.filePath, + Buffer.from(mdxContent), + context.tutorialApp, + ); + expect(actions).toEqual(expectedActions); + }); + } + + function itShouldFailWhenExtractingActions( + testName: string, + mdxContent: string, + errorMessage: string, + ) { + it(`should fail extracting actions: ${testName}`, async () => { + await expect( + getActionsFromMdxContent( + context.filePath, + Buffer.from(mdxContent), + context.tutorialApp, + ), + ).rejects.toThrow(errorMessage); + }); + } + + itShouldExtractActions( + "INIT_APP action", + ` +# Tutorial + + + +Some content here. +`, + [ + { + id: "init-project", + sourceTutorialFilePath: context.filePath, + kind: "INIT_APP", + waspStarterTemplateName: "basic", + }, + ], + ); + + itShouldExtractActions( + "APPLY_PATCH action", + ` +# Tutorial + + + +\`\`\` +const someCode = "" +\`\`\` + + +Some content here. + `, + [ + { + id: "add-feature", + sourceTutorialFilePath: context.filePath, + kind: "APPLY_PATCH", + displayName: "01-intro.md / add-feature", + patchFilePath: "/test/tutorial/patches/01-intro__add-feature.patch", + }, + ], + ); + + itShouldExtractActions( + "MIGRATE_DB action", + ` +# Tutorial + + + +Some content here. + `, + [ + { + id: "migrate-schema", + sourceTutorialFilePath: context.filePath, + kind: "MIGRATE_DB", + }, + ], + ); + + itShouldExtractActions( + "multiple actions", + ` +# Tutorial + + + +Some content here. + + +\`\`\` +const someCode = "" +\`\`\` + + +More content. + + + `, + [ + { + id: "init-project", + sourceTutorialFilePath: context.filePath, + kind: "INIT_APP", + waspStarterTemplateName: "basic", + }, + { + id: "add-feature", + sourceTutorialFilePath: context.filePath, + kind: "APPLY_PATCH", + displayName: "01-intro.md / add-feature", + patchFilePath: "/test/tutorial/patches/01-intro__add-feature.patch", + }, + { + id: "migrate-schema", + sourceTutorialFilePath: context.filePath, + kind: "MIGRATE_DB", + }, + ], + ); + + itShouldExtractActions( + "no actions present", + ` +# Tutorial + +Just some regular content without any tutorial actions. + `, + [], + ); + + itShouldExtractActions( + "should ignore non-TutorialAction MDX components", + ` +# Tutorial + + + + + + + `, + [ + { + id: "valid-action", + sourceTutorialFilePath: context.filePath, + kind: "MIGRATE_DB", + }, + ], + ); + + itShouldFailWhenExtractingActions( + "when action attribute is missing", + ` + + `, + "TutorialAction component requires the 'action' attribute", + ); + + itShouldFailWhenExtractingActions( + "when id attribute is missing", + ` + + `, + "TutorialAction component requires the 'id' attribute", + ); + + itShouldFailWhenExtractingActions( + "when INIT_APP action is missing starterTemplateName", + ` + + `, + "TutorialAction with action 'INIT_APP' requires the 'starterTemplateName' attribute", + ); + + itShouldFailWhenExtractingActions( + "for unknown action type", + ` + + `, + "Unknown action 'UNKNOWN_ACTION' in TutorialAction component", + ); +}); From 19aec4043f00cff2fef1382dbbb50cc833366cf3 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 10 Oct 2025 14:55:50 +0200 Subject: [PATCH 42/48] Update sort and filter fn names --- .../src/extract-actions/index.ts | 8 ++++---- .../tests/extract-actions/index.test.ts | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 5336216fe9..7acfdb7101 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -41,21 +41,21 @@ async function getTutorialFilePaths( tutorialDir: TutorialDirPath, ): Promise { const files = await fs.readdir(tutorialDir); - return sortTutorialFileNames(filterTutorialFileNames(files)).map( + return sortFileNamesByNumberedPrefix(getMarkdownFileNames(files)).map( (file) => path.resolve(tutorialDir, file) as MdxFilePath, ); } -export function filterTutorialFileNames(filePaths: string[]): string[] { +export function getMarkdownFileNames(filePaths: string[]): string[] { return filePaths.filter((file) => file.endsWith(".md")); } /** - * Tutorial files are named "01-something.md" and we want to sort them by the number prefix. + * Sorts a list of files which are named "01-something.md" by their numeric prefix. * @param filePaths * @returns Sorted file paths */ -export function sortTutorialFileNames(filePaths: string[]): string[] { +export function sortFileNamesByNumberedPrefix(filePaths: string[]): string[] { return filePaths.sort((a, b) => { const aNumber = parseInt(a.split("-")[0]!, 10); const bNumber = parseInt(b.split("-")[0]!, 10); diff --git a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts index 77166f29da..8143d54480 100644 --- a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts +++ b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it } from "vitest"; import type { MdxFilePath } from "../../src/actions/actions"; import { - filterTutorialFileNames, getActionsFromMdxContent, getAttributeValue, - sortTutorialFileNames, + getMarkdownFileNames, + sortFileNamesByNumberedPrefix, } from "../../src/extract-actions/index"; import type { AppDirPath, @@ -46,21 +46,21 @@ describe("getAttributeValue", () => { }); }); -describe("filterTutorialFileNames", () => { +describe("getMarkdownFileNames", () => { it("should filter files ending with .md", () => { const files = ["01-intro.md", "02-setup.md", "README.txt", "package.json"]; - const result = filterTutorialFileNames(files); + const result = getMarkdownFileNames(files); expect(result).toEqual(["01-intro.md", "02-setup.md"]); }); }); -describe("sortTutorialFileNames", () => { +describe("sortFileNamesByNumberedPrefix", () => { it("should sort files by numeric prefix", () => { const files = ["03-advanced.md", "01-intro.md", "02-setup.md"]; - const result = sortTutorialFileNames(files); + const result = sortFileNamesByNumberedPrefix(files); expect(result).toEqual(["01-intro.md", "02-setup.md", "03-advanced.md"]); }); From 85ff5798ef02c98dd1b5600adad102ea8a208374 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 10 Oct 2025 15:42:13 +0200 Subject: [PATCH 43/48] Extract example MDX files --- .../tests/extract-actions/exampleMdxFiles.ts | 78 +++++++ .../tests/extract-actions/index.test.ts | 195 ++++++------------ 2 files changed, 142 insertions(+), 131 deletions(-) create mode 100644 web/tutorial-actions-executor/tests/extract-actions/exampleMdxFiles.ts diff --git a/web/tutorial-actions-executor/tests/extract-actions/exampleMdxFiles.ts b/web/tutorial-actions-executor/tests/extract-actions/exampleMdxFiles.ts new file mode 100644 index 0000000000..4fa9daac0c --- /dev/null +++ b/web/tutorial-actions-executor/tests/extract-actions/exampleMdxFiles.ts @@ -0,0 +1,78 @@ +export const mdxWithInitAppAction = ` +# Tutorial + + + +Some content here. +`; + +export const mdxWithApplyPatchAction = ` +# Tutorial + + + +\`\`\` +const someCode = "" +\`\`\` + + +Some content here. +`; + +export const mdxWithMigrateDbAction = ` +# Tutorial + + + +Some content here. +`; + +export const mdxWithMultipleActions = ` +# Tutorial + + + +Some content here. + + +\`\`\` +const someCode = "" +\`\`\` + + +More content. + + +`; + +export const mdxWithNoActions = ` +# Tutorial + +Just some regular content without any tutorial actions. +`; + +export const mdxWithNonTutorialActionComponents = ` +# Tutorial + + + + + + +`; + +export const mdxWithMissingActionAttribute = ` + +`; + +export const mdxWithMissingIdAttribute = ` + +`; + +export const mdxWithMissingStarterTemplateName = ` + +`; + +export const mdxWithUnknownActionType = ` + +`; diff --git a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts index 8143d54480..b99054b14f 100644 --- a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts +++ b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts @@ -15,6 +15,18 @@ import type { PatchesDirPath, TutorialDirPath, } from "../../src/tutorialApp"; +import { + mdxWithApplyPatchAction, + mdxWithInitAppAction, + mdxWithMigrateDbAction, + mdxWithMissingActionAttribute, + mdxWithMissingIdAttribute, + mdxWithMissingStarterTemplateName, + mdxWithMultipleActions, + mdxWithNoActions, + mdxWithNonTutorialActionComponents, + mdxWithUnknownActionType, +} from "./exampleMdxFiles"; describe("getAttributeValue", () => { it("should return correct value when attributes exist", () => { @@ -109,130 +121,59 @@ describe("getActionsFromMdxContent", () => { }); } - itShouldExtractActions( - "INIT_APP action", - ` -# Tutorial - - - -Some content here. -`, - [ - { - id: "init-project", - sourceTutorialFilePath: context.filePath, - kind: "INIT_APP", - waspStarterTemplateName: "basic", - }, - ], - ); - - itShouldExtractActions( - "APPLY_PATCH action", - ` -# Tutorial - - - -\`\`\` -const someCode = "" -\`\`\` - - -Some content here. - `, - [ - { - id: "add-feature", - sourceTutorialFilePath: context.filePath, - kind: "APPLY_PATCH", - displayName: "01-intro.md / add-feature", - patchFilePath: "/test/tutorial/patches/01-intro__add-feature.patch", - }, - ], - ); - - itShouldExtractActions( - "MIGRATE_DB action", - ` -# Tutorial - - - -Some content here. - `, - [ - { - id: "migrate-schema", - sourceTutorialFilePath: context.filePath, - kind: "MIGRATE_DB", - }, - ], - ); - - itShouldExtractActions( - "multiple actions", - ` -# Tutorial - - - -Some content here. - - -\`\`\` -const someCode = "" -\`\`\` - - -More content. - - - `, - [ - { - id: "init-project", - sourceTutorialFilePath: context.filePath, - kind: "INIT_APP", - waspStarterTemplateName: "basic", - }, - { - id: "add-feature", - sourceTutorialFilePath: context.filePath, - kind: "APPLY_PATCH", - displayName: "01-intro.md / add-feature", - patchFilePath: "/test/tutorial/patches/01-intro__add-feature.patch", - }, - { - id: "migrate-schema", - sourceTutorialFilePath: context.filePath, - kind: "MIGRATE_DB", - }, - ], - ); + itShouldExtractActions("INIT_APP action", mdxWithInitAppAction, [ + { + id: "init-project", + sourceTutorialFilePath: context.filePath, + kind: "INIT_APP", + waspStarterTemplateName: "basic", + }, + ]); + + itShouldExtractActions("APPLY_PATCH action", mdxWithApplyPatchAction, [ + { + id: "add-feature", + sourceTutorialFilePath: context.filePath, + kind: "APPLY_PATCH", + displayName: "01-intro.md / add-feature", + patchFilePath: "/test/tutorial/patches/01-intro__add-feature.patch", + }, + ]); - itShouldExtractActions( - "no actions present", - ` -# Tutorial + itShouldExtractActions("MIGRATE_DB action", mdxWithMigrateDbAction, [ + { + id: "migrate-schema", + sourceTutorialFilePath: context.filePath, + kind: "MIGRATE_DB", + }, + ]); + + itShouldExtractActions("multiple actions", mdxWithMultipleActions, [ + { + id: "init-project", + sourceTutorialFilePath: context.filePath, + kind: "INIT_APP", + waspStarterTemplateName: "basic", + }, + { + id: "add-feature", + sourceTutorialFilePath: context.filePath, + kind: "APPLY_PATCH", + displayName: "01-intro.md / add-feature", + patchFilePath: "/test/tutorial/patches/01-intro__add-feature.patch", + }, + { + id: "migrate-schema", + sourceTutorialFilePath: context.filePath, + kind: "MIGRATE_DB", + }, + ]); -Just some regular content without any tutorial actions. - `, - [], - ); + itShouldExtractActions("no actions present", mdxWithNoActions, []); itShouldExtractActions( "should ignore non-TutorialAction MDX components", - ` -# Tutorial - - - - - - - `, + mdxWithNonTutorialActionComponents, [ { id: "valid-action", @@ -244,33 +185,25 @@ Just some regular content without any tutorial actions. itShouldFailWhenExtractingActions( "when action attribute is missing", - ` - - `, + mdxWithMissingActionAttribute, "TutorialAction component requires the 'action' attribute", ); itShouldFailWhenExtractingActions( "when id attribute is missing", - ` - - `, + mdxWithMissingIdAttribute, "TutorialAction component requires the 'id' attribute", ); itShouldFailWhenExtractingActions( "when INIT_APP action is missing starterTemplateName", - ` - - `, + mdxWithMissingStarterTemplateName, "TutorialAction with action 'INIT_APP' requires the 'starterTemplateName' attribute", ); itShouldFailWhenExtractingActions( "for unknown action type", - ` - - `, + mdxWithUnknownActionType, "Unknown action 'UNKNOWN_ACTION' in TutorialAction component", ); }); From f65fb22f3422e772c629d26e055ae2fab5d5b356 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 10 Oct 2025 15:46:23 +0200 Subject: [PATCH 44/48] Remove Git explanantion and update action name --- web/tutorial-actions-executor/README.md | 82 ++----------------- web/tutorial-actions-executor/package.json | 2 +- .../index.ts | 8 +- web/tutorial-actions-executor/src/index.ts | 4 +- 4 files changed, 12 insertions(+), 84 deletions(-) rename web/tutorial-actions-executor/src/commands/{edit-action => edit-patch-action}/index.ts (94%) diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index 6d8dc3e803..d6672548ad 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -34,22 +34,22 @@ One of the actions is to apply a Git patch that modifies a source file. If apply a patch fails due to conflicts, `generate-app` command pauses and allows you to resolve the conflicts manually. -### 2. Edit Action (`npm run edit-action`) +### 2. Edit Patch Action (`npm run edit-patch-action`) Allows you to modify a specific patch action and automatically reapplies all subsequent actions. ```bash # Non-interactive (direct by ID): -npm run edit-action -- --action-id "create-task-entity" +npm run edit-patch-action -- --action-id "create-task-entity" # Interactive (pick an action from a list): -npm run edit-action +npm run edit-patch-action # Optional flags: # - skip generating app before editing -npm run edit-action -- --skip-generating-app +npm run edit-patch-action -- --skip-generating-app # - pass a custom Wasp CLI -npm run edit-action -- --wasp-cli-command wasp +npm run edit-patch-action -- --wasp-cli-command wasp ``` This command: @@ -100,75 +100,3 @@ The tool extracts these components and uses: - `id`: Unique identifier for the action (becomes commit message) - `action`: Type of action (`INIT_APP`, `APPLY_PATCH`, `MIGRATE_DB`) - -## How It Works: Git-Based Workflow - -This tool uses a Git-based workflow to manage tutorial actions: - -### Executing Tutorial Actions - -1. **Initial Setup**: Creates a Git repository with an initial commit -2. **Action Execution**: Each tutorial action is executed and committed as a separate Git commit, - with the action ID as the commit message - -### Action Editing Process - -When editing a tutorial action (e.g., action 4 out of 10 total actions): - -#### Phase 1: Setup and Branching - -```bash -# Generate app with all 10 actions, each as a commit -git init -git commit -m "Initial commit" -git commit -m "action-1-setup" -git commit -m "action-2-auth" -git commit -m "action-3-database" -git commit -m "action-4-create-task-entity" # ← Target action -# ... and so on - -# Create branch from action 4's commit -git switch --force-create fixes -``` - -#### Phase 2: User Editing - -```bash -# Move action 4's changes to staging area -git reset --soft HEAD~1 - -# User edits the code in their editor -# User confirms when done -``` - -#### Phase 3: Patch Creation and Application - -```bash -# Commit changes and generate a new patch -git add . -git commit -m "temporary-commit" -git show HEAD --format= > new-patch.patch -git reset --hard HEAD~1 - -# Apply the new patch and commit with the original action ID -git apply new-patch.patch -git commit -m "action-4-create-task-entity" -``` - -#### Phase 4: Rebasing and Integration - -```bash -# Switch back to main branch and rebase the fixes -git switch main -git rebase fixes # This integrates the fixed action 4 - -# If conflicts occur, user resolves them like any other Git conflict -``` - -#### Phase 5: Regenerate Patch Files - -```bash -# Regenerate patch files from the updated commits -# For each action after the edited one: -git show action-commit-sha --format= > patches/action-N.patch -``` diff --git a/web/tutorial-actions-executor/package.json b/web/tutorial-actions-executor/package.json index 09d49fe807..9cc4903836 100644 --- a/web/tutorial-actions-executor/package.json +++ b/web/tutorial-actions-executor/package.json @@ -4,7 +4,7 @@ "type": "module", "license": "MIT", "scripts": { - "edit-action": "tsx ./src/index.ts edit-action", + "edit-patch-action": "tsx ./src/index.ts edit-patch-action", "generate-app": "tsx ./src/index.ts generate-app", "list-actions": "tsx ./src/index.ts list-actions", "test": "vitest" diff --git a/web/tutorial-actions-executor/src/commands/edit-action/index.ts b/web/tutorial-actions-executor/src/commands/edit-patch-action/index.ts similarity index 94% rename from web/tutorial-actions-executor/src/commands/edit-action/index.ts rename to web/tutorial-actions-executor/src/commands/edit-patch-action/index.ts index a7f6db3ac8..5fb6265eef 100644 --- a/web/tutorial-actions-executor/src/commands/edit-action/index.ts +++ b/web/tutorial-actions-executor/src/commands/edit-patch-action/index.ts @@ -24,8 +24,8 @@ import type { WaspCliCommand } from "../../waspCli"; import { waspCliCommandOption } from "../commonOptions"; import { generateApp } from "../generate-app"; -export const editActionCommand = new Command("edit-action") - .description("Edit a action in the tutorial app") +export const editPatchActionCommand = new Command("edit-patch-action") + .description("Edit a patch action in the tutorial app") .addOption(new Option("--action-id ", "ID of the action to edit")) .addOption( new Option( @@ -55,14 +55,14 @@ export const editActionCommand = new Command("edit-action") log("info", `Editing action ${action.displayName}...`); - await editActionPatch({ appDir: tutorialApp.dirPath, action }); + await editPatchActionPatch({ appDir: tutorialApp.dirPath, action }); await extractCommitsIntoPatches(actions); log("success", `Edit completed for action ${action.displayName}!`); }); -async function editActionPatch({ +async function editPatchActionPatch({ appDir, action, }: { diff --git a/web/tutorial-actions-executor/src/index.ts b/web/tutorial-actions-executor/src/index.ts index eae44c4432..9373f3ffab 100644 --- a/web/tutorial-actions-executor/src/index.ts +++ b/web/tutorial-actions-executor/src/index.ts @@ -1,12 +1,12 @@ import { program } from "@commander-js/extra-typings"; -import { editActionCommand } from "./commands/edit-action"; +import { editPatchActionCommand } from "./commands/edit-patch-action"; import { generateAppCommand } from "./commands/generate-app"; import { listActionsCommand } from "./commands/list-actions"; program .addCommand(generateAppCommand) - .addCommand(editActionCommand) + .addCommand(editPatchActionCommand) .addCommand(listActionsCommand) .parse(process.argv) .opts(); From 7a1a3da7ed02b69dbfb9c0738f20b25f04ef7fbb Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 10 Oct 2025 15:47:00 +0200 Subject: [PATCH 45/48] Update actions to action types --- web/tutorial-actions-executor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/tutorial-actions-executor/README.md b/web/tutorial-actions-executor/README.md index d6672548ad..55d9d8480b 100644 --- a/web/tutorial-actions-executor/README.md +++ b/web/tutorial-actions-executor/README.md @@ -30,7 +30,7 @@ This command: - Applies each action in sequence (e.g. initialize app, apply patches, migrate DB) - Results in a fully functional application -One of the actions is to apply a Git patch that modifies a source file. If applying +One of the action types is to apply a Git patch that modifies a source file. If applying a patch fails due to conflicts, `generate-app` command pauses and allows you to resolve the conflicts manually. From 9dff3f1f77ec3eb7fe485a6267110b16d3b4a00b Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 10 Oct 2025 16:56:55 +0200 Subject: [PATCH 46/48] Handle both md and mdx extensions --- web/tutorial-actions-executor/package.json | 2 +- .../src/extract-actions/index.ts | 17 +++++--- .../tests/extract-actions/index.test.ts | 42 ++++++++++++++++--- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/web/tutorial-actions-executor/package.json b/web/tutorial-actions-executor/package.json index 9cc4903836..3f6b1579f5 100644 --- a/web/tutorial-actions-executor/package.json +++ b/web/tutorial-actions-executor/package.json @@ -7,7 +7,7 @@ "edit-patch-action": "tsx ./src/index.ts edit-patch-action", "generate-app": "tsx ./src/index.ts generate-app", "list-actions": "tsx ./src/index.ts list-actions", - "test": "vitest" + "test": "vitest run" }, "dependencies": { "@commander-js/extra-typings": "^14.0.0", diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 7acfdb7101..14f197416c 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -46,17 +46,24 @@ async function getTutorialFilePaths( ); } -export function getMarkdownFileNames(filePaths: string[]): string[] { - return filePaths.filter((file) => file.endsWith(".md")); +const SUPPORTED_TUTORIAL_FILE_EXTENSIONS = ["md", "mdx"] as const; + +export function getMarkdownFileNames(fileNames: string[]): string[] { + return fileNames.filter((fileName) => { + const lowerFileName = fileName.toLowerCase(); + return SUPPORTED_TUTORIAL_FILE_EXTENSIONS.some((ext) => + lowerFileName.endsWith(`.${ext}`), + ); + }); } /** * Sorts a list of files which are named "01-something.md" by their numeric prefix. - * @param filePaths + * @param fileNames * @returns Sorted file paths */ -export function sortFileNamesByNumberedPrefix(filePaths: string[]): string[] { - return filePaths.sort((a, b) => { +export function sortFileNamesByNumberedPrefix(fileNames: string[]): string[] { + return fileNames.sort((a, b) => { const aNumber = parseInt(a.split("-")[0]!, 10); const bNumber = parseInt(b.split("-")[0]!, 10); return aNumber - bNumber; diff --git a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts index b99054b14f..cca466eb62 100644 --- a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts +++ b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts @@ -59,13 +59,45 @@ describe("getAttributeValue", () => { }); describe("getMarkdownFileNames", () => { - it("should filter files ending with .md", () => { - const files = ["01-intro.md", "02-setup.md", "README.txt", "package.json"]; + function itShouldFilterMarkdownFiles( + testName: string, + inputFiles: string[], + expectedFiles: string[], + ) { + it(testName, () => { + expect(getMarkdownFileNames(inputFiles)).toEqual(expectedFiles); + }); + } - const result = getMarkdownFileNames(files); + itShouldFilterMarkdownFiles( + "should return empty array when no markdown files are present", + ["file.txt", "image.png", "document.pdf"], + [], + ); - expect(result).toEqual(["01-intro.md", "02-setup.md"]); - }); + itShouldFilterMarkdownFiles( + "should filter files ending with .md or .mdx", + [ + "01-intro.md", + "02-setup.mdx", + "03-advanced.md", + "README.txt", + "package.json", + ], + ["01-intro.md", "02-setup.mdx", "03-advanced.md"], + ); + + itShouldFilterMarkdownFiles( + "should handle case-insensitive file extensions", + ["README.MD", "guide.MDX", "tutorial.Md", "docs.mDx", "file.txt"], + ["README.MD", "guide.MDX", "tutorial.Md", "docs.mDx"], + ); + + itShouldFilterMarkdownFiles( + "should not match extensions in the middle of filename", + ["file.md.txt", "file.mdx.backup", "README.md"], + ["README.md"], + ); }); describe("sortFileNamesByNumberedPrefix", () => { From 0ef5164e6bb8972937a9f54c2a0de210c46b7ba5 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 10 Oct 2025 17:00:48 +0200 Subject: [PATCH 47/48] Update naming --- .../src/extract-actions/index.ts | 4 ++-- .../tests/extract-actions/index.test.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 14f197416c..77d411b4ef 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -41,14 +41,14 @@ async function getTutorialFilePaths( tutorialDir: TutorialDirPath, ): Promise { const files = await fs.readdir(tutorialDir); - return sortFileNamesByNumberedPrefix(getMarkdownFileNames(files)).map( + return sortFileNamesByNumberedPrefix(filterValidTutorialFileNames(files)).map( (file) => path.resolve(tutorialDir, file) as MdxFilePath, ); } const SUPPORTED_TUTORIAL_FILE_EXTENSIONS = ["md", "mdx"] as const; -export function getMarkdownFileNames(fileNames: string[]): string[] { +export function filterValidTutorialFileNames(fileNames: string[]): string[] { return fileNames.filter((fileName) => { const lowerFileName = fileName.toLowerCase(); return SUPPORTED_TUTORIAL_FILE_EXTENSIONS.some((ext) => diff --git a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts index cca466eb62..af9652e898 100644 --- a/web/tutorial-actions-executor/tests/extract-actions/index.test.ts +++ b/web/tutorial-actions-executor/tests/extract-actions/index.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest"; import type { MdxFilePath } from "../../src/actions/actions"; import { + filterValidTutorialFileNames, getActionsFromMdxContent, getAttributeValue, - getMarkdownFileNames, sortFileNamesByNumberedPrefix, } from "../../src/extract-actions/index"; import type { @@ -58,24 +58,24 @@ describe("getAttributeValue", () => { }); }); -describe("getMarkdownFileNames", () => { - function itShouldFilterMarkdownFiles( +describe("filterValidTutorialFileNames", () => { + function itShouldFilterTutorialFileNames( testName: string, inputFiles: string[], expectedFiles: string[], ) { it(testName, () => { - expect(getMarkdownFileNames(inputFiles)).toEqual(expectedFiles); + expect(filterValidTutorialFileNames(inputFiles)).toEqual(expectedFiles); }); } - itShouldFilterMarkdownFiles( + itShouldFilterTutorialFileNames( "should return empty array when no markdown files are present", ["file.txt", "image.png", "document.pdf"], [], ); - itShouldFilterMarkdownFiles( + itShouldFilterTutorialFileNames( "should filter files ending with .md or .mdx", [ "01-intro.md", @@ -87,13 +87,13 @@ describe("getMarkdownFileNames", () => { ["01-intro.md", "02-setup.mdx", "03-advanced.md"], ); - itShouldFilterMarkdownFiles( + itShouldFilterTutorialFileNames( "should handle case-insensitive file extensions", ["README.MD", "guide.MDX", "tutorial.Md", "docs.mDx", "file.txt"], ["README.MD", "guide.MDX", "tutorial.Md", "docs.mDx"], ); - itShouldFilterMarkdownFiles( + itShouldFilterTutorialFileNames( "should not match extensions in the middle of filename", ["file.md.txt", "file.mdx.backup", "README.md"], ["README.md"], From 6c3935d11d65d21f145f192819457602ff9f73f3 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 10 Oct 2025 17:04:39 +0200 Subject: [PATCH 48/48] Cleanup JSDoc --- web/tutorial-actions-executor/src/extract-actions/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/tutorial-actions-executor/src/extract-actions/index.ts b/web/tutorial-actions-executor/src/extract-actions/index.ts index 77d411b4ef..478ae3cbcd 100644 --- a/web/tutorial-actions-executor/src/extract-actions/index.ts +++ b/web/tutorial-actions-executor/src/extract-actions/index.ts @@ -59,8 +59,6 @@ export function filterValidTutorialFileNames(fileNames: string[]): string[] { /** * Sorts a list of files which are named "01-something.md" by their numeric prefix. - * @param fileNames - * @returns Sorted file paths */ export function sortFileNamesByNumberedPrefix(fileNames: string[]): string[] { return fileNames.sort((a, b) => {