Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions typescript/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# cooklang (TypeScript wrapper)

Lightweight TypeScript wrapper through WASM for the Rust-based Cooklang parser.

This folder provides a thin JS/TS convenience layer around the WASM parser based on `cooklang-rs`. The primary exported class in this module is `CooklangParser` which can be used either as an instance (hold a recipe and operate on it) or as a functional utility (pass a recipe string to each method).

## Examples

### Instance Usage

This pattern holds a recipe on the parser instance in which all properties and methods then act upon.

```ts
import { CooklangParser } from "@cooklang/parser";

const fancyRecipe = "Write your @recipe here!";

// create a parser instance with a raw recipe string
const recipe = new CooklangParser(fancyRecipe);

// read basic fields populated by the wrapper
console.log(recipe.metadata); // TODO sample response
console.log(recipe.ingredients); // TODO sample response
console.log(recipe.sections); // TODO sample response

// render methods return the original string in the minimal implementation
console.log(recipe.renderPrettyString()); // TODO sample response
console.log(recipe.renderHTML()); // TODO sample response
```

### Functional Usage

This pattern passes a string directly and doesn't require keeping an instance around.

```ts
import { CooklangParser } from "@cooklang/parser";

const parser = new CooklangParser();
const recipeString = "Write your @recipe here!";

// functional helpers accept a recipe string and return rendered output
console.log(parser.renderPrettyString(recipeString)); // TODO sample response
console.log(parser.renderHTML(recipeString)); // TODO sample response

// `parse` returns a recipe class
const parsed = parser.parse(recipeString);
console.log(parsed.metadata); // TODO sample response
console.log(parsed.ingredients); // TODO sample response
console.log(parsed.sections); // TODO sample response
```
5 changes: 1 addition & 4 deletions typescript/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
import { version, Parser } from "./pkg/cooklang_wasm";

export { version, Parser };
export type { ScaledRecipeWithReport } from "./pkg/cooklang_wasm";
export * from "./src/parser";
3 changes: 3 additions & 0 deletions typescript/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub struct ScaledRecipeWithReport {
report: String,
}

// TODO see if we can pull this out of an impl
// and use simple functions which may make our TS
// easier to manage and check, move the class creation to JS
#[wasm_bindgen]
impl Parser {
#[wasm_bindgen(constructor)]
Expand Down
122 changes: 122 additions & 0 deletions typescript/src/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {
version,
Parser as RustParser,
type ScaledRecipeWithReport,
} from "../pkg/cooklang_wasm";

// for temporary backwards compatibility, let's export it with the old name
const Parser = RustParser;
export { version, Parser, type ScaledRecipeWithReport };

class CooklangRecipe {
Copy link

Choose a reason for hiding this comment

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

I'm sure you have a great reason for this, but I was a bit confused why we wouldn't use the interface Recipe that is exposed by the wasm build. It neatly provides types for each of the members as well.

On one hand, we do expose the TS package to a wide array of changes on the Rust package. On the other hand, we might have a lot of manual work to keep the packages in-sync with each other.

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This isn't purely for types. My expectation which in retrospect didn't come across very obvious is that we use the types generated from the WASM build in this class. Some we may be able to use directly, like ingredients. However, the sections is rather verbose. It felt reasonable to provide a bit of sugar on top to improve the ability to render it.

I split this off as it's own class so we could access each of these like a property on an object, e.g. recipe.ingredients.

metadata = {};
ingredients = new Map();
// TODO should we use something other than array here?
sections = [];
cookware = new Map();
timers = [];
constructor(rawParsed?: ScaledRecipeWithReport) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Copying interface from Number, perhaps we want a function parseRecipe and make this constructor take raw string and call it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This class isn't exported. With that we can always call the parse beforehand. Also this thought may be related here.

if (rawParsed) {
this.setRecipe(rawParsed);
}
}

setRecipe(rawParsed: ScaledRecipeWithReport) {
this.metadata = {};
// this.ingredients = [];
// this.steps = [];
// this.cookware = [];
// this.timers = [];
}
}

class CooklangParser extends CooklangRecipe {
Copy link
Contributor

Choose a reason for hiding this comment

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

Parser being its result feels weird. Is it some JS idiom? If not, woudn't it be better if parse() just returned CooklangRecipe?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry, but I don't understand your question. With the functional usage, it does.

Copy link
Contributor

Choose a reason for hiding this comment

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

I mean you basically change the type, from Parser to CooklangRecipe, and this change is hidden from type system and therefore from the user. It feels to me that normal solution would be "parser returns recipe", not "parser becomes recipe".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We also control Parser in this package. That Rust file is only for this package. I anticipate that Rust file will change anyways if only to simply it to make type generation more robust.

public version: string;
public extensionList: string[];
constructor(public rawContent?: string) {
super();
this.version = version();
this.extensionList = [] as string[];
}

set raw(raw: string) {
this.rawContent = raw;
}

get raw() {
if (!this.rawContent)
throw new Error("recipe not set, call .raw(content) to set it first");
return this.rawContent;
}

#handleFunctionalOrInstance(instanceInput: string | undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like 2 classes to me, perhaps inheriting some common code?

Copy link

@panzer panzer Aug 21, 2025

Choose a reason for hiding this comment

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

I agree with @michalmoc here. I was worried about a user reaching the error conditions within this method, when they can be avoided with an improved design.

We might want to take inspiration from yaml, Markdown, and Date.

Here's an example:

import { CooklangRecipe, CooklangRenderer } from "@cooklang/parser";

const recipeBlank = new CooklangRecipe();
const recipe = new CooklangRecipe("Write your @recipe here!", {someOption: true});
const recipeClone = new CooklangRecipe(recipe);

console.log(recipe.toString()); // Same as render(CooklangRenderer.BasicString)
console.log(recipe.render(CooklangRenderer.PrettyString));
console.log(recipe.render(CooklangRenderer.Html)); // TODO sample response

// Since CooklangRecipe initialization already did the parse, to access data simply:
console.log(recipe.metadata); // TODO sample response
console.log(recipe.ingredients); // TODO sample response
console.log(recipe.sections); // TODO sample response

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Correct. There are two classes @michalmoc. One extends the other though so it does inherit that code.

@panzer Aye, I understand the concern for these errors. An alternative that I had considered is having two completely separate paths whereby if you give a value to the initial constructor, it will use a class specifically intended for "instance use" or the "functional" use. I do like the though of adding a toString() which could mirror the "pretty string" perhaps.

There is a bit of a balance to be had, as we don't want to require building up so many classes / objects, as a user may be generating or rendering 1000s of these. Part of the reason I went through this exercise as exactly this discussion. We want to be able to discuss the public API, and it is much easier to share input after doing this. Happy to discuss and work through it.

Copy link
Contributor

Choose a reason for hiding this comment

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

By two classes I mean: if you have the same if in every major method, then obviously those are two different implementations, i.e. two classes deriving from the same one.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, I see. We do have effectively two separate code paths with lots of overlapping functions. We could make the classes smaller and do more extends to separate out the code paths and remove #handleFunctionalOrInstance from each. As I was talking through it and organically built it up, I pulled out #handleFunctionalOrInstance as a separate method. In retrospect, I did consider splitting the code paths more if only to make the types and uses of more obvious in your IDE.

if (this.rawContent) {
if (instanceInput)
throw new Error("recipe already set, create a new instance");
return this.rawContent;
}
if (!instanceInput) {
throw new Error("pass a recipe as a string or generate a new instance");
}
return instanceInput;
}

// TODO create issue to fill this in
set extensions(extensions: string[]) {
this.extensionList = extensions;
}

get extensions() {
if (!this.extensionList) throw new Error("TODO");
return this.extensionList;
}

// TODO create issue for this
renderPrettyString(recipeString?: string) {
Copy link
Contributor

Choose a reason for hiding this comment

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

We will need more render types, e.g. renderObsidian which will be similar to renderHtml but not identical. For that reason I'd propose to use visitor pattern, similar to rendering calendar in python - render function would walk through structure and visitor will handle actual rendering allowing easy reuse of code via inheritance.

Copy link

Choose a reason for hiding this comment

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

Yeah, I'd prefer if additional processing on top of the parsed-data would be separate from this class

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@michalmoc Hmm, yea I haven't looked at the Obsidian or VSCode implementation. I think to @panzer's point, we probably don't want to overload this class. I presumed that in the cases where we need to walk the tree, we would rely on parseRaw. Have you looked at the existing implementations? Would that be sufficient?

@panzer are you suggesting that we actually remove the renderHTML and renderPrettyString from this class?

Copy link

Choose a reason for hiding this comment

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

Yeah that is what I'm suggesting. I thinking building in certain renderers into the core API should be done cautiously. Simple toString render is IMO necessary, but HTML, markdown, png, audio-file... we open up the flood gates potentially.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Aye, in my mind, this was the limit of what we should do in the core. Sounds like HTML is the only one in question? I am not hard set on it being part of core, but I think having it minimally available somewhere in this package is useful. Helpful for those plan VanillaJS type demos / examples.

Copy link

Choose a reason for hiding this comment

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

That makes sense. I can get onboard with HTML as one that we ship with the package. I did want to make sure we had a plan for other types of conversion/rendering which seems to be a common request. Obsidian being one example. What you've put forward makes sense!

const input = this.#handleFunctionalOrInstance(recipeString);
// TODO renderPrettyString this then return
return input;
}

renderHTML(recipeString?: string) {
const input = this.#handleFunctionalOrInstance(recipeString);
// TODO renderHTML this then return
return input;
}

parseRaw(recipeString?: string) {
const input = this.#handleFunctionalOrInstance(recipeString);
// TODO parseRaw this then return
return input;
}

// TODO return fully typed JS Object
parse(recipeString?: string) {
const input = this.#handleFunctionalOrInstance(recipeString);
// TODO actually parse
const parsed = {
recipe: { ingredients: [input] },
} as unknown as ScaledRecipeWithReport;
if (this.rawContent) {
this.setRecipe(parsed);
}
if (!this.rawContent && recipeString) {
Copy link
Contributor

Choose a reason for hiding this comment

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

guaranteed by #handleFunctionalOrInstance(recipeString), no need to check again

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yea.... we know this, but Typescript was struggling to properly infer this. It kept expecting the return to possibly be undefined. If we can figure out a better way to type narrow, happy to adjust this.

const direct = new CooklangRecipe(parsed);
return direct;
} else {
throw new Error("should never reach this");
}
}

debug(recipeString?: string): {
version: string;
ast: string;
events: string;
} {
const input = this.#handleFunctionalOrInstance(recipeString);
// TODO debug parse this then return
return { version: this.version, ast: input, events: input };
}
}

export { CooklangParser };
93 changes: 93 additions & 0 deletions typescript/test/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { beforeAll, describe, expect, it } from "vitest";
import { CooklangParser } from "../src/parser";

it("returns version number", () => {
const parser = new CooklangParser();
expect(parser.version).toBeDefined();
});

describe("create and use instance", () => {
let recipe: CooklangParser;
beforeAll(() => {
const recipeString = "hello";
recipe = new CooklangParser(recipeString);
});

it("returns pretty stringified recipe", () => {
expect(recipe.renderPrettyString()).toEqual("hello");
});

it("returns basic html recipe", () => {
expect(recipe.renderPrettyString()).toEqual("hello");
});

it("returns metadata list", () => {
expect(recipe.metadata).toEqual({});
});

it("returns ingredients list", () => {
expect(recipe.ingredients).toEqual(new Map());
});

it("returns sections list", () => {
expect(recipe.sections).toEqual([]);
});

it("returns cookware list", () => {
expect(recipe.cookware).toEqual(new Map());
});

it("returns timers list", () => {
expect(recipe.timers).toEqual([]);
});
});

describe("functional", () => {
const parser = new CooklangParser();
const recipe = "hello";
it("returns pretty stringified recipe", () => {
const parsedRecipe = parser.renderPrettyString(recipe);
expect(parsedRecipe).toEqual("hello");
});

it("returns basic html recipe", () => {
const parsedRecipe = parser.renderHTML(recipe);
expect(parsedRecipe).toEqual("hello");
});

it("returns full parse of recipe string", () => {
const parsedRecipe = parser.parse(recipe);
expect(parsedRecipe).toEqual({
cookware: new Map(),
ingredients: new Map(),
metadata: {},
sections: [],
timers: [],
});
});

it("returns metadata list", () => {
const parsedRecipe = parser.parse(recipe);
expect(parsedRecipe.metadata).toEqual({});
});

it("returns ingredients list", () => {
const parsedRecipe = parser.parse(recipe);
expect(parsedRecipe.ingredients).toEqual(new Map());
});

it("returns sections list", () => {
const parsedRecipe = parser.parse(recipe);
expect(parsedRecipe.sections).toEqual([]);
});

it("returns cookware list", () => {
const parsedRecipe = parser.parse(recipe);
expect(parsedRecipe.cookware).toEqual(new Map());
});

it("returns timers list", () => {
const parsedRecipe = parser.parse(recipe);
expect(parsedRecipe.timers).toEqual([]);
});
});