From 754a17a89593cbc4f94cd80cf41c0903c1916c2d Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Wed, 20 Aug 2025 12:18:21 -0500 Subject: [PATCH 1/5] base JS/TS class prepared for method hook ups --- typescript/README.md | 50 ++++++++++++++ typescript/index.ts | 5 +- typescript/src/lib.rs | 3 + typescript/src/parser.ts | 122 +++++++++++++++++++++++++++++++++ typescript/test/parser.test.ts | 93 +++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 typescript/README.md create mode 100644 typescript/src/parser.ts create mode 100644 typescript/test/parser.test.ts 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/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..269564d --- /dev/null +++ b/typescript/src/parser.ts @@ -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 { + metadata = {}; + ingredients = new Map(); + // TODO should we use something other than array here? + sections = []; + cookware = new Map(); + timers = []; + constructor(rawParsed?: ScaledRecipeWithReport) { + if (rawParsed) { + this.setRecipe(rawParsed); + } + } + + setRecipe(rawParsed: ScaledRecipeWithReport) { + this.metadata = {}; + // this.ingredients = []; + // this.steps = []; + // this.cookware = []; + // this.timers = []; + } +} + +class CooklangParser extends CooklangRecipe { + 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) { + 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) { + 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) { + 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 }; diff --git a/typescript/test/parser.test.ts b/typescript/test/parser.test.ts new file mode 100644 index 0000000..fc98072 --- /dev/null +++ b/typescript/test/parser.test.ts @@ -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([]); + }); +}); From b071f3f9d69f8c083c42e005aa2999eeb17c7584 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 22 Aug 2025 01:31:49 -0500 Subject: [PATCH 2/5] add simple bench, wire up parse for better bench --- typescript/package.json | 3 +- typescript/src/parser.ts | 25 ++++++---- typescript/test/parser.bench.ts | 19 ++++++++ typescript/test/parser.test.ts | 85 +++++++++++++++++++++------------ 4 files changed, 90 insertions(+), 42 deletions(-) create mode 100644 typescript/test/parser.bench.ts 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/parser.ts b/typescript/src/parser.ts index 269564d..1d83023 100644 --- a/typescript/src/parser.ts +++ b/typescript/src/parser.ts @@ -22,8 +22,10 @@ class CooklangRecipe { } setRecipe(rawParsed: ScaledRecipeWithReport) { - this.metadata = {}; - // this.ingredients = []; + // this.metadata = {}; + this.ingredients = new Map( + rawParsed.recipe.ingredients.map((recipe) => [recipe.name, recipe]) + ); // this.steps = []; // this.cookware = []; // this.timers = []; @@ -33,10 +35,18 @@ class CooklangRecipe { class CooklangParser extends CooklangRecipe { public version: string; public extensionList: string[]; + #rust_parser: RustParser; constructor(public rawContent?: string) { - super(); + const rustParser = new RustParser(); + if (rawContent) { + const parsed = rustParser.parse(rawContent); + super(parsed); + } else { + super(); + } this.version = version(); this.extensionList = [] as string[]; + this.#rust_parser = rustParser; } set raw(raw: string) { @@ -90,19 +100,14 @@ class CooklangParser extends CooklangRecipe { 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; + const parsed = this.#rust_parser.parse(input); if (this.rawContent) { this.setRecipe(parsed); } if (!this.rawContent && recipeString) { - const direct = new CooklangRecipe(parsed); - return direct; + return new CooklangRecipe(parsed); } else { throw new Error("should never reach this"); } diff --git a/typescript/test/parser.bench.ts b/typescript/test/parser.bench.ts new file mode 100644 index 0000000..6110600 --- /dev/null +++ b/typescript/test/parser.bench.ts @@ -0,0 +1,19 @@ +import { bench, describe } from "vitest"; +import { CooklangParser } from "../src/parser"; + +const recipeString = "Make your first @recipe!"; +describe("parser", () => { + bench("instance", () => { + const recipe = new CooklangParser(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 index fc98072..2bc47e2 100644 --- a/typescript/test/parser.test.ts +++ b/typescript/test/parser.test.ts @@ -1,6 +1,32 @@ import { beforeAll, describe, expect, it } from "vitest"; import { CooklangParser } from "../src/parser"; +const recipeString = "Make your first recipe with an @ingredient!"; +const assertParsed = { + cookware: new Map(), + ingredients: new Map([ + [ + "ingredient", + { + alias: null, + name: "ingredient", + note: null, + quantity: null, + reference: null, + relation: { + defined_in_step: true, + reference_target: null, + referenced_from: [], + type: "definition", + }, + }, + ], + ]), + metadata: {}, + sections: [], + timers: [], +}; + it("returns version number", () => { const parser = new CooklangParser(); expect(parser.version).toBeDefined(); @@ -9,85 +35,82 @@ it("returns version number", () => { describe("create and use instance", () => { let recipe: CooklangParser; beforeAll(() => { - const recipeString = "hello"; recipe = new CooklangParser(recipeString); + console.dir(recipe); }); it("returns pretty stringified recipe", () => { - expect(recipe.renderPrettyString()).toEqual("hello"); + expect(recipe.renderPrettyString()).toEqual( + "Make your first recipe with an @ingredient!" + ); }); it("returns basic html recipe", () => { - expect(recipe.renderPrettyString()).toEqual("hello"); + expect(recipe.renderHTML()).toEqual( + "Make your first recipe with an @ingredient!" + ); }); it("returns metadata list", () => { - expect(recipe.metadata).toEqual({}); + expect(recipe.metadata).toEqual(assertParsed.metadata); }); it("returns ingredients list", () => { - expect(recipe.ingredients).toEqual(new Map()); + expect(recipe.ingredients).toEqual(assertParsed.ingredients); }); it("returns sections list", () => { - expect(recipe.sections).toEqual([]); + expect(recipe.sections).toEqual(assertParsed.sections); }); it("returns cookware list", () => { - expect(recipe.cookware).toEqual(new Map()); + expect(recipe.cookware).toEqual(assertParsed.cookware); }); it("returns timers list", () => { - expect(recipe.timers).toEqual([]); + expect(recipe.timers).toEqual(assertParsed.timers); }); }); describe("functional", () => { const parser = new CooklangParser(); - const recipe = "hello"; it("returns pretty stringified recipe", () => { - const parsedRecipe = parser.renderPrettyString(recipe); - expect(parsedRecipe).toEqual("hello"); + const parsedRecipe = parser.renderPrettyString(recipeString); + expect(parsedRecipe).toEqual("Make your first recipe with an @ingredient!"); }); it("returns basic html recipe", () => { - const parsedRecipe = parser.renderHTML(recipe); - expect(parsedRecipe).toEqual("hello"); + const parsedRecipe = parser.renderHTML(recipeString); + expect(parsedRecipe).toEqual("Make your first recipe with an @ingredient!"); }); it("returns full parse of recipe string", () => { - const parsedRecipe = parser.parse(recipe); - expect(parsedRecipe).toEqual({ - cookware: new Map(), - ingredients: new Map(), - metadata: {}, - sections: [], - timers: [], - }); + const parsedRecipe = parser.parse(recipeString); + expect(parsedRecipe).toEqual(assertParsed); }); it("returns metadata list", () => { - const parsedRecipe = parser.parse(recipe); - expect(parsedRecipe.metadata).toEqual({}); + const parsedRecipe = parser.parse(recipeString); + expect(parsedRecipe.metadata).toEqual(assertParsed.metadata); }); it("returns ingredients list", () => { - const parsedRecipe = parser.parse(recipe); - expect(parsedRecipe.ingredients).toEqual(new Map()); + const parsedRecipe = parser.parse(recipeString); + expect(parsedRecipe.ingredients).toEqual(assertParsed.ingredients); }); it("returns sections list", () => { - const parsedRecipe = parser.parse(recipe); - expect(parsedRecipe.sections).toEqual([]); + const parsedRecipe = parser.parse(recipeString); + expect(parsedRecipe.sections).toEqual(assertParsed.sections); }); it("returns cookware list", () => { - const parsedRecipe = parser.parse(recipe); - expect(parsedRecipe.cookware).toEqual(new Map()); + const parsedRecipe = parser.parse(recipeString); + expect(parsedRecipe.cookware).toEqual(assertParsed.cookware); }); it("returns timers list", () => { - const parsedRecipe = parser.parse(recipe); - expect(parsedRecipe.timers).toEqual([]); + const parsedRecipe = parser.parse(recipeString); + expect(parsedRecipe.timers).toEqual(assertParsed.timers); }); }); From ff45a89112521a1de8fd41e9aa2530044d65eb92 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 22 Aug 2025 02:20:00 -0500 Subject: [PATCH 3/5] change .raw reparses --- typescript/src/parser.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/typescript/src/parser.ts b/typescript/src/parser.ts index 1d83023..9d929e6 100644 --- a/typescript/src/parser.ts +++ b/typescript/src/parser.ts @@ -37,25 +37,22 @@ class CooklangParser extends CooklangRecipe { public extensionList: string[]; #rust_parser: RustParser; constructor(public rawContent?: string) { - const rustParser = new RustParser(); - if (rawContent) { - const parsed = rustParser.parse(rawContent); - super(parsed); - } else { - super(); - } + super(); this.version = version(); this.extensionList = [] as string[]; - this.#rust_parser = rustParser; + this.#rust_parser = new RustParser(); + if (rawContent) this.raw = rawContent; } set raw(raw: string) { this.rawContent = raw; + const parsed = this.#rust_parser.parse(raw); + this.setRecipe(parsed); } get raw() { if (!this.rawContent) - throw new Error("recipe not set, call .raw(content) to set it first"); + throw new Error('recipe not set, set .raw = "content" to set it first'); return this.rawContent; } From 9a4c99e861e26dd559e6b512b2e568cd4101b2e1 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sun, 14 Sep 2025 01:20:59 -0500 Subject: [PATCH 4/5] refactor in hopefully more clear use cases --- typescript/src/parser.ts | 197 +++++++++++++++++-------------- typescript/test/cooklang.test.ts | 45 +++++++ typescript/test/parser.test.ts | 116 ++++-------------- 3 files changed, 180 insertions(+), 178 deletions(-) create mode 100644 typescript/test/cooklang.test.ts diff --git a/typescript/src/parser.ts b/typescript/src/parser.ts index 9d929e6..661d80c 100644 --- a/typescript/src/parser.ts +++ b/typescript/src/parser.ts @@ -8,64 +8,17 @@ import { const Parser = RustParser; export { version, Parser, type ScaledRecipeWithReport }; -class CooklangRecipe { - metadata = {}; - ingredients = new Map(); - // TODO should we use something other than array here? - sections = []; - cookware = new Map(); - timers = []; - constructor(rawParsed?: ScaledRecipeWithReport) { - if (rawParsed) { - this.setRecipe(rawParsed); - } - } - - setRecipe(rawParsed: ScaledRecipeWithReport) { - // this.metadata = {}; - this.ingredients = new Map( - rawParsed.recipe.ingredients.map((recipe) => [recipe.name, recipe]) - ); - // this.steps = []; - // this.cookware = []; - // this.timers = []; - } -} +type Renderer = (parser: CooklangParser) => { + render: (recipeString: string) => any; +}; -class CooklangParser extends CooklangRecipe { - public version: string; +export class CooklangParser { + static version: string = version(); public extensionList: string[]; #rust_parser: RustParser; - constructor(public rawContent?: string) { - super(); - this.version = version(); + constructor() { this.extensionList = [] as string[]; this.#rust_parser = new RustParser(); - if (rawContent) this.raw = rawContent; - } - - set raw(raw: string) { - this.rawContent = raw; - const parsed = this.#rust_parser.parse(raw); - this.setRecipe(parsed); - } - - get raw() { - if (!this.rawContent) - throw new Error('recipe not set, set .raw = "content" to set it first'); - return this.rawContent; - } - - #handleFunctionalOrInstance(instanceInput: string | undefined) { - 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 @@ -78,47 +31,117 @@ class CooklangParser extends CooklangRecipe { return this.extensionList; } - // TODO create issue for this - renderPrettyString(recipeString?: string) { - const input = this.#handleFunctionalOrInstance(recipeString); - // TODO renderPrettyString this then return - return input; + 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); } +} + +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]) + ), + }; +}; + +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!); - renderHTML(recipeString?: string) { - const input = this.#handleFunctionalOrInstance(recipeString); - // TODO renderHTML this then return - return input; + this.raw = raw; } - parseRaw(recipeString?: string) { - const input = this.#handleFunctionalOrInstance(recipeString); - // TODO parseRaw this then return - return input; + #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; } - parse(recipeString?: string) { - const input = this.#handleFunctionalOrInstance(recipeString); - const parsed = this.#rust_parser.parse(input); - if (this.rawContent) { - this.setRecipe(parsed); - } - if (!this.rawContent && recipeString) { - return new CooklangRecipe(parsed); - } else { - throw new Error("should never reach this"); - } + set raw(raw: string) { + const parsed = this.parse(raw); + this.#parsed = parsed; + this.#setRecipe(parsed); } - 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 }; + get raw() { + return this.raw; } } - -export { CooklangParser }; diff --git a/typescript/test/cooklang.test.ts b/typescript/test/cooklang.test.ts new file mode 100644 index 0000000..7bb9071 --- /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/parser"; + +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.test.ts b/typescript/test/parser.test.ts index 2bc47e2..ff009e8 100644 --- a/typescript/test/parser.test.ts +++ b/typescript/test/parser.test.ts @@ -1,116 +1,50 @@ -import { beforeAll, describe, expect, it } from "vitest"; -import { CooklangParser } from "../src/parser"; +import { describe, expect, it } from "vitest"; +import { CooklangParser, CooklangRendererBase } from "../src/parser"; const recipeString = "Make your first recipe with an @ingredient!"; -const assertParsed = { - cookware: new Map(), - ingredients: new Map([ - [ - "ingredient", - { - alias: null, - name: "ingredient", - note: null, - quantity: null, - reference: null, - relation: { - defined_in_step: true, - reference_target: null, - referenced_from: [], - type: "definition", - }, - }, - ], - ]), - metadata: {}, - sections: [], - timers: [], -}; it("returns version number", () => { - const parser = new CooklangParser(); - expect(parser.version).toBeDefined(); + expect(CooklangParser.version).toBeDefined(); }); -describe("create and use instance", () => { - let recipe: CooklangParser; - beforeAll(() => { - recipe = new CooklangParser(recipeString); - console.dir(recipe); +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(recipe.renderPrettyString()).toEqual( - "Make your first recipe with an @ingredient!" - ); + expect( + parser.render(CooklangRendererBase.prettyString, recipeString) + ).toEqual("Make your first recipe with an @ingredient!"); }); - it("returns basic html recipe", () => { - expect(recipe.renderHTML()).toEqual( + it("returns html recipe", () => { + expect(parser.render(CooklangRendererBase.html, recipeString)).toEqual( "Make your first recipe with an @ingredient!" ); }); - - it("returns metadata list", () => { - expect(recipe.metadata).toEqual(assertParsed.metadata); - }); - - it("returns ingredients list", () => { - expect(recipe.ingredients).toEqual(assertParsed.ingredients); - }); - - it("returns sections list", () => { - expect(recipe.sections).toEqual(assertParsed.sections); - }); - - it("returns cookware list", () => { - expect(recipe.cookware).toEqual(assertParsed.cookware); - }); - - it("returns timers list", () => { - expect(recipe.timers).toEqual(assertParsed.timers); - }); }); -describe("functional", () => { - const parser = new CooklangParser(); - it("returns pretty stringified recipe", () => { - const parsedRecipe = parser.renderPrettyString(recipeString); - expect(parsedRecipe).toEqual("Make your first recipe with an @ingredient!"); - }); - - it("returns basic html recipe", () => { - const parsedRecipe = parser.renderHTML(recipeString); - expect(parsedRecipe).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(parsedRecipe).toEqual(assertParsed); - }); - - it("returns metadata list", () => { - const parsedRecipe = parser.parse(recipeString); - expect(parsedRecipe.metadata).toEqual(assertParsed.metadata); + expect(typeof parsedRecipe).toEqual("object"); }); - it("returns ingredients list", () => { - const parsedRecipe = parser.parse(recipeString); - expect(parsedRecipe.ingredients).toEqual(assertParsed.ingredients); - }); - - it("returns sections list", () => { - const parsedRecipe = parser.parse(recipeString); - expect(parsedRecipe.sections).toEqual(assertParsed.sections); - }); - - it("returns cookware list", () => { - const parsedRecipe = parser.parse(recipeString); - expect(parsedRecipe.cookware).toEqual(assertParsed.cookware); + it("returns pretty stringified recipe", () => { + expect(parser.render.prettyString(recipeString)).toEqual( + "Make your first recipe with an @ingredient!" + ); }); - it("returns timers list", () => { - const parsedRecipe = parser.parse(recipeString); - expect(parsedRecipe.timers).toEqual(assertParsed.timers); + it("returns html recipe", () => { + expect(parser.render.html(recipeString)).toEqual( + "Make your first recipe with an @ingredient!" + ); }); }); From 6a32ee4d83598bbca2d8f1dede3230e4f2388746 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sun, 14 Sep 2025 01:33:00 -0500 Subject: [PATCH 5/5] split into separate files --- typescript/src/full.ts | 46 +++++++++++++++ typescript/src/index.ts | 3 + typescript/src/parser.ts | 96 +------------------------------- typescript/src/renderers.ts | 53 ++++++++++++++++++ typescript/test/cooklang.test.ts | 2 +- typescript/test/parser.bench.ts | 4 +- typescript/test/parser.test.ts | 2 +- 7 files changed, 107 insertions(+), 99 deletions(-) create mode 100644 typescript/src/full.ts create mode 100644 typescript/src/index.ts create mode 100644 typescript/src/renderers.ts 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/parser.ts b/typescript/src/parser.ts index 661d80c..2cff5a4 100644 --- a/typescript/src/parser.ts +++ b/typescript/src/parser.ts @@ -3,15 +3,12 @@ import { 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 }; -type Renderer = (parser: CooklangParser) => { - render: (recipeString: string) => any; -}; - export class CooklangParser { static version: string = version(); public extensionList: string[]; @@ -54,94 +51,3 @@ export class CooklangParser { return this.#rust_parser.parse(recipeString); } } - -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]) - ), - }; -}; - -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/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 index 7bb9071..5d61b2c 100644 --- a/typescript/test/cooklang.test.ts +++ b/typescript/test/cooklang.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from "vitest"; -import { CooklangRecipe, recipe as recipeFn } from "../src/parser"; +import { CooklangRecipe, recipe as recipeFn } from "../src"; const recipeString = "Make your first recipe with an @ingredient!"; diff --git a/typescript/test/parser.bench.ts b/typescript/test/parser.bench.ts index 6110600..0dfe6a9 100644 --- a/typescript/test/parser.bench.ts +++ b/typescript/test/parser.bench.ts @@ -1,10 +1,10 @@ import { bench, describe } from "vitest"; -import { CooklangParser } from "../src/parser"; +import { CooklangParser, CooklangRecipe } from "../src"; const recipeString = "Make your first @recipe!"; describe("parser", () => { bench("instance", () => { - const recipe = new CooklangParser(recipeString); + const recipe = new CooklangRecipe(recipeString); }); // init the parser outside of the bench which diff --git a/typescript/test/parser.test.ts b/typescript/test/parser.test.ts index ff009e8..63a32ac 100644 --- a/typescript/test/parser.test.ts +++ b/typescript/test/parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { CooklangParser, CooklangRendererBase } from "../src/parser"; +import { CooklangParser, CooklangRendererBase } from "../src"; const recipeString = "Make your first recipe with an @ingredient!";