Skip to content

Conversation

infomiho
Copy link
Contributor

@infomiho infomiho commented May 9, 2025

This PR implements running all the steps from the tutorial in the docs. We encode all the code changes into Git patches which we apply one by one.

We implement tooling for working with the patches when something breaks:

  • if there is a new step and a patch is missing - it prompts you to do the code change and it then creates a patch
  • if a patch fails, it prompts you to redo the code change and then it creates a new patch

There are two commands:

  • generate-app which handles creating and apply patches
  • edit-step which is used to edit a patch and it rebases the changes

Check out the README.md for more info on using the

Note for reviewers: please try it out and think of any process that might be useful to cover with the CLI

Closes #2814

Copy link
Member

@cprecioso cprecioso left a comment

Choose a reason for hiding this comment

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

I think that, for consistency, I'd also have write actions take in a patch.

Copy link

cloudflare-workers-and-pages bot commented Jul 16, 2025

Deploying wasp-docs-on-main with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6c3935d
Status: ✅  Deploy successful!
Preview URL: https://672e0989.wasp-docs-on-main.pages.dev
Branch Preview URL: https://miho-tutorial-app-from-docs.wasp-docs-on-main.pages.dev

View logs

@infomiho infomiho changed the title Tutorial app from Markdown WIP Automatically generate tutorial app from Markdown Jul 16, 2025
@infomiho infomiho force-pushed the miho-tutorial-app-from-docs branch from a260193 to 1348c89 Compare July 18, 2025 09:12
@infomiho infomiho requested a review from cprecioso July 18, 2025 10:50

Tutorial actions are defined in MDX files using JSX components:

````markdown
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
````markdown
````mdx

Copy link
Contributor

Choose a reason for hiding this comment

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

We can use this on any code block with MDX.

@infomiho
Copy link
Contributor Author

infomiho commented Oct 6, 2025

@FranjoMindek @Martinsos ready for another look

Copy link
Member

@Martinsos Martinsos left a comment

Choose a reason for hiding this comment

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

@infomiho I reviewed once again, but I have to admit I didn't go into TS code -> there is just too much of it. I would say we leave it as it is, it will be good enough.

Only thing I wonder, if it is missing, is e2e test(s). I commented on it before, maybe having a single test would be valuable. I wonder how do you know test if tacte works? You run it manually? If so, we wnat to have a test for it. Let's discuss it a bit at least, any ideas? How hard are they, would it make sense to do it now? if not, why not?

@@ -0,0 +1,120 @@
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.
Copy link
Member

Choose a reason for hiding this comment

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

Btw I see we keep using step and action interchangeably. I think you did make it uniform in the code, so it is Actions, and that is good, but it is very natural to say Step, and we do sometimes say it in the docs I think. I wonder if "step" is really the word we should be using all the time, and not "action". How did we end up with "action", and what do you thikn about that? Isn't it only introducing confusion? Because "Tutorial Actions Executor" makes me wonder, ok what are these actions? While "Tutorial Step Executor" wouldn't make me wonder about what step is, it is clear. Or even just "Tutorial Executor", then I would just assume it executes tutorial steps.
I know this question sounds blah, because who will rename all that, but on the other hand, much better to take care of this now then to have to do it later, and I am already thinking about open sourcing.


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 `<TutorialAction>` components that define machine-executable steps,
Copy link
Member

Choose a reason for hiding this comment

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

Just an example, notice how you used step 5 times here, and then you have TutorialAction, which again you say is a step. So whyd o we call it an action is the question.

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`)
Copy link
Member

Choose a reason for hiding this comment

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

Should it be called edit-patch-action?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the name 👍

Comment on lines +57 to +61
- 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
Copy link
Member

Choose a reason for hiding this comment

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

Either periods at the ends, or no capital letter at the start. You have more bullet points lists like this one for same goes for those.

import type { AppDirPath } from "../tutorialApp";
import type { Action, ApplyPatchAction } from "./actions";

export function commitActionChanges({
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't sem like his function does much? It just strips id from action? Is it worth having it for that? I guess types. But is it wroth having comitAllChanges then? Why not combine these two?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've built this in layers:

commitActionChanges -> commitAllChanges -> git command
  • commitAllChanges expects a string commit message
  • commitActionChanges expects an Action -> which calls commitAllChanges with the action ID as the commit message

Copy link
Contributor

@FranjoMindek FranjoMindek left a comment

Choose a reason for hiding this comment

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

I went over changes since last review.

);
}

export async function getActionsFromMdxContent(
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure we need this function? Why not keep it inline?
I don't feel we gain anything?

Worse yet we still pass the sourceTutorialFilePath even if it is named getActionsFromMdxContent?
Why do we need file path if it works on content?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I totally forgot to add a unit test for this function. I wanted to do IO for reading the content in one function and then do the actual parsing of the content in getActionsFromMdxContent.

I've added the unit tests now 👍

Also, created an issue to run the unit tests in the CI: #3247

Copy link
Contributor

Choose a reason for hiding this comment

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

I wanted to do IO for reading the content in one function and then do the actual parsing of the content in getActionsFromMdxContent.

But why?
That is my question. What's the motivation here? What do we gain?
We don't abstract anything useful in my opinion.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The getActionsFromMdxFile function reads a file from the disk and I didn't want to mock reading file from the disk to be able to test the core logic. This way I have a pure function which I test by passing in a string and see if the output is okay 🤷

Would you rather mock fs.readFile?

Copy link
Contributor

Choose a reason for hiding this comment

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

Hm I agree that purity is great.
However, I think that the way we separate them is not great currently.
Something is off about it to me.

I tried to follow my senses a bit, but I still didn't mange to do it to be happy enough.
Maybe this inspires something in you?

I'll leave it up to your decision.

async function getActionsFromMdxFile(
  sourceTutorialFilePath: MdxFilePath,
  tutorialApp: TutorialApp,
): Promise<Action[]> {
  try {
    const mdxFileContent = await fs.readFile(
      path.resolve(sourceTutorialFilePath),
    );
    const tutorialActionAttributes =
      await parseTutorialActionNodesFromMdxContent(mdxFileContent);

    return tutorialActionAttributes.map((tutorialActionNode) =>
      createTutorialAction(
        tutorialApp,
        sourceTutorialFilePath,
        tutorialActionNode,
      ),
    );
  } catch (err) {
    throw new Error(
      `Error processing tutorial file '${sourceTutorialFilePath}'`,
      { cause: err },
    );
  }
}

export async function parseTutorialActionNodesFromMdxContent(
  fileContent: Buffer<ArrayBufferLike>,
): Promise<TutorialActionAttributes[]> {
  const nodes: TutorialActionAttributes[] = [];

  const ast = fromMarkdown(fileContent, {
    extensions: [mdxJsx({ acorn, addResult: true })],
    mdastExtensions: [mdxJsxFromMarkdown()],
  });

  const tutorialComponentName = "TutorialAction";
  visit(ast, "mdxJsxFlowElement", (node) => {
    if (node.name !== tutorialComponentName) {
      return;
    }
    nodes.push(parseTutorialActionNode(node));
  });

  return nodes;
}

// would be better if it was some discriminated union
// but I don't know your types too well
// it should be easily inferred somewhere in the type system
// or I think we are doing something wrong
interface TutorialActionAttributes {
  actionId: ActionId;
  actionKind: Action["kind"];
  starterTemplateName: string | null;
}

function parseTutorialActionNode(
  tutorialActionNode: MdxJsxFlowElement,
): TutorialActionAttributes {
  const actionId = getAttributeValue(
    tutorialActionNode,
    "id",
  ) as ActionId | null;
  if (!actionId) {
    throw new Error(
      // TODO: give meaningful location where this error happened
      `TutorialAction component requires the 'id' attribute.  ${tutorialActionNode.position}`,
    );
  }

  const actionKind = getAttributeValue(tutorialActionNode, "action") as
    | Action["kind"]
    | null;
  if (!actionKind) {
    throw new Error(
      // TODO: give meaningful location where this error happened
      `TutorialAction component requires the 'action' attribute. ${tutorialActionNode.position}`,
    );
  }

  const starterTemplateName = getAttributeValue(
    tutorialActionNode,
    "starterTemplateName",
  );
  if (actionKind === "INIT_APP" && !starterTemplateName) {
    // TODO: give meaningful location where this error happened
    throw new Error(
      `TutorialAction component with action 'INIT_APP' requires the 'starterTemplateName' attribute. ${tutorialActionNode.position}`,
    );
  }

  return {
    actionId,
    actionKind,
    starterTemplateName,
  };
}

function createTutorialAction(
  tutorialApp: TutorialApp,
  sourceTutorialFilePath: MdxFilePath,
  { actionKind, actionId, starterTemplateName }: TutorialActionAttributes,
): Action {
  const commonData: BaseAction = {
    id: actionId,
    sourceTutorialFilePath,
  };

  switch (actionKind) {
    case "INIT_APP":
      // if we make TutorialActionAttributes a discriminated union
      // we wouldn't need the ! here
      return createInitAppAction(commonData, starterTemplateName!);
    case "APPLY_PATCH":
      return createApplyPatchAction(
        commonData,
        tutorialApp.docsTutorialPatchesPath,
      );
    case "MIGRATE_DB":
      return createMigrateDbAction(commonData);
    default:
      assertUnreachable(
        actionKind,
        `Unknown action '${actionKind}' in TutorialAction component`,
      );
  }
}

export function sortTutorialFileNames(filePaths: string[]): string[] {
/**
* Sorts a list of files which are named "01-something.md" by their numeric prefix.
* @param filePaths
Copy link
Contributor

Choose a reason for hiding this comment

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

@param filePaths is unnecessary I think with typescript.
We don't really want to add any types to this, so why even have it at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It was auto-generated and I left it here, I'll delete it 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Automatically test the tutorial app

4 participants