Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 2 additions & 1 deletion typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"build-wasm": "wasm-pack build --target bundler",
"watch-wasm": "wasm-pack build --dev --mode no-install --target bundler",
"prepare": "npm run build-wasm && npm run build",
"test": "vitest"
"test": "vitest",
"bench": "vitest bench"
},
"devDependencies": {
"vite-plugin-wasm": "^3.4.1",
Expand Down
46 changes: 46 additions & 0 deletions typescript/src/full.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { CooklangParser, ScaledRecipeWithReport } from "./parser.js";
import { CooklangRendererBase, recipe } from "./renderers.js";

export class CooklangRecipe extends CooklangParser {
#parsed: ScaledRecipeWithReport | null = null;
metadata = {};
ingredients = new Map();
// TODO should we use something other than array here?
sections = [];
cookware = new Map();
timers = [];
constructor(raw: string) {
super();
// @ts-expect-error
this.use = void 0; // disable use method on instance
this.render = {} as Record<string, () => any>;

this.render.prettyString = () =>
CooklangRendererBase["prettyString"](this).renderWithParsed(
this.#parsed!
);
this.render.html = () =>
CooklangRendererBase["html"](this).renderWithParsed(this.#parsed!);

this.raw = raw;
}

#setRecipe(rawParsed: ScaledRecipeWithReport) {
const constructed = recipe(rawParsed);
this.metadata = constructed.metadata;
this.ingredients = constructed.ingredients;
this.sections = constructed.sections;
this.cookware = constructed.cookware;
this.timers = constructed.timers;
}

set raw(raw: string) {
const parsed = this.parse(raw);
this.#parsed = parsed;
this.#setRecipe(parsed);
}

get raw() {
return this.raw;
}
}
3 changes: 3 additions & 0 deletions typescript/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./parser";
export * from "./renderers";
export * from "./full";
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
53 changes: 53 additions & 0 deletions typescript/src/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
version,
Parser as RustParser,
type ScaledRecipeWithReport,
} from "../pkg/cooklang_wasm";
import type { Renderer } from "./renderers";

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

export class CooklangParser {
static version: string = version();
public extensionList: string[];
#rust_parser: RustParser;
constructor() {
this.extensionList = [] as string[];
this.#rust_parser = new RustParser();
}

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

use(...renderers: Record<string, Renderer>[]) {
for (const rendererObject of renderers) {
for (const key in rendererObject) {
if ((this as any).render[key])
throw new Error(`Renderer key ${key} already exists on parser`);
const renderer = rendererObject[key];
if (typeof renderer !== "function")
throw new Error(`Renderer ${key} is not a function`);
const instance = renderer(this);
(this as any).render[key] = instance.render;
}
}
return this;
}

render(renderFunction: Renderer, recipeString: string) {
return renderFunction(this).render(recipeString);
}

parse(recipeString: string) {
return this.#rust_parser.parse(recipeString);
}
}
53 changes: 53 additions & 0 deletions typescript/src/renderers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { CooklangParser } from "./parser.js";
import { type ScaledRecipeWithReport } from "../pkg/cooklang_wasm";

export type Renderer = (parser: CooklangParser) => {
render: (recipeString: string) => any;
};

export const CooklangRendererBase = {
prettyString(parser: CooklangParser) {
return {
// TODO fix return with actual pretty string
render: (recipeString: string) => recipeString,
// only for class CooklangRecipe, not required on other external renderers
renderWithParsed: (parsed: ScaledRecipeWithReport) =>
"eventually pretty string",
};
},
html(parser: CooklangParser) {
return {
// TODO fix return with actual html string
render: (recipeString: string) => recipeString,
// only for class CooklangRecipe, not required on other external renderers
renderWithParsed: (parsed: ScaledRecipeWithReport) => "eventually html",
};
},
debug(parser: CooklangParser) {
// TODO debug parse this then return
return {
render: (recipeString: string) => ({
version: CooklangParser.version,
ast: recipeString,
events: recipeString,
}),
};
},
recipe(parser: CooklangParser) {
return {
render: (recipeString: string) => {
const parsed = parser.parse(recipeString);
return recipe(parsed);
},
};
},
};

export const recipe = (rawParsed: ScaledRecipeWithReport) => {
return {
...rawParsed.recipe,
ingredients: new Map(
rawParsed.recipe.ingredients.map((recipe) => [recipe.name, recipe])
),
};
};
45 changes: 45 additions & 0 deletions typescript/test/cooklang.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { beforeAll, describe, expect, it } from "vitest";
import { CooklangRecipe, recipe as recipeFn } from "../src";

const recipeString = "Make your first recipe with an @ingredient!";

it("returns version number", () => {
expect(CooklangRecipe.version).toBeDefined();
});

describe("parser instance", () => {
let recipe: any;
let directParse: any;
beforeAll(() => {
recipe = new CooklangRecipe(recipeString);
directParse = recipeFn(recipe.parse(recipeString));
});

it("returns pretty stringified recipe", () => {
expect(recipe.render.prettyString()).toEqual("eventually pretty string");
});

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

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

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

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

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

it("returns timers list", () => {
expect(recipe.timers).toEqual(directParse.timers);
});
});
19 changes: 19 additions & 0 deletions typescript/test/parser.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { bench, describe } from "vitest";
import { CooklangParser, CooklangRecipe } from "../src";

const recipeString = "Make your first @recipe!";
describe("parser", () => {
bench("instance", () => {
const recipe = new CooklangRecipe(recipeString);
});

// init the parser outside of the bench which
// technically saves a few cycles on the actual
// bench result vs the instance bench, but this is
// effectively the use case where you init once
// and reuse that parser over and over
Copy link

Choose a reason for hiding this comment

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

Good to point out that this is a major optimization! I'm thinking the "right way" should be the way we guide users towards through the library design. That's why I used the static parser in #60.

To say it another way: I don't see the benefit of "instance" parsing. What do you think?

const parser = new CooklangParser();
bench("functional", () => {
parser.parse(recipeString);
});
});
50 changes: 50 additions & 0 deletions typescript/test/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { CooklangParser, CooklangRendererBase } from "../src";

const recipeString = "Make your first recipe with an @ingredient!";

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

describe("parser with functional render", () => {
const parser = new CooklangParser();

it("returns full parse of recipe string", () => {
const parsedRecipe = parser.parse(recipeString);
expect(typeof parsedRecipe).toEqual("object");
});

it("returns pretty stringified recipe", () => {
expect(
parser.render(CooklangRendererBase.prettyString, recipeString)
).toEqual("Make your first recipe with an @ingredient!");
});

it("returns html recipe", () => {
expect(parser.render(CooklangRendererBase.html, recipeString)).toEqual(
"Make your first recipe with an @ingredient!"
);
});
});

describe("parser using renderer", () => {
const parser = new CooklangParser().use(CooklangRendererBase);

it("returns full parse of recipe string", () => {
const parsedRecipe = parser.parse(recipeString);
expect(typeof parsedRecipe).toEqual("object");
});

it("returns pretty stringified recipe", () => {
expect(parser.render.prettyString(recipeString)).toEqual(
"Make your first recipe with an @ingredient!"
);
});

it("returns html recipe", () => {
expect(parser.render.html(recipeString)).toEqual(
"Make your first recipe with an @ingredient!"
);
});
});