diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 0000000..ba4fedd --- /dev/null +++ b/typescript/README.md @@ -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 +``` diff --git a/typescript/index.ts b/typescript/index.ts index affb57a..2bb91b0 100644 --- a/typescript/index.ts +++ b/typescript/index.ts @@ -1,4 +1 @@ -import { version, Parser } from "./pkg/cooklang_wasm"; - -export { version, Parser }; -export type { ScaledRecipeWithReport } from "./pkg/cooklang_wasm"; +export * from "./src/parser"; diff --git a/typescript/package.json b/typescript/package.json index 8219ffb..404a0db 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -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", diff --git a/typescript/src/full.ts b/typescript/src/full.ts new file mode 100644 index 0000000..23a7ffd --- /dev/null +++ b/typescript/src/full.ts @@ -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 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; + } +} diff --git a/typescript/src/index.ts b/typescript/src/index.ts new file mode 100644 index 0000000..1a23fb2 --- /dev/null +++ b/typescript/src/index.ts @@ -0,0 +1,3 @@ +export * from "./parser"; +export * from "./renderers"; +export * from "./full"; diff --git a/typescript/src/lib.rs b/typescript/src/lib.rs index aabdf06..57b86c5 100644 --- a/typescript/src/lib.rs +++ b/typescript/src/lib.rs @@ -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)] diff --git a/typescript/src/parser.ts b/typescript/src/parser.ts new file mode 100644 index 0000000..2cff5a4 --- /dev/null +++ b/typescript/src/parser.ts @@ -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[]) { + 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); + } +} diff --git a/typescript/src/renderers.ts b/typescript/src/renderers.ts new file mode 100644 index 0000000..867af29 --- /dev/null +++ b/typescript/src/renderers.ts @@ -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]) + ), + }; +}; diff --git a/typescript/test/cooklang.test.ts b/typescript/test/cooklang.test.ts new file mode 100644 index 0000000..5d61b2c --- /dev/null +++ b/typescript/test/cooklang.test.ts @@ -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); + }); +}); diff --git a/typescript/test/parser.bench.ts b/typescript/test/parser.bench.ts new file mode 100644 index 0000000..0dfe6a9 --- /dev/null +++ b/typescript/test/parser.bench.ts @@ -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 + const parser = new CooklangParser(); + bench("functional", () => { + parser.parse(recipeString); + }); +}); diff --git a/typescript/test/parser.test.ts b/typescript/test/parser.test.ts new file mode 100644 index 0000000..63a32ac --- /dev/null +++ b/typescript/test/parser.test.ts @@ -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!" + ); + }); +});