Skip to content
Merged
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
20 changes: 10 additions & 10 deletions src/classes/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
getAlternativeSignature,
parseMarkdownSegments,
} from "../utils/parser_helpers";
import { normalizeInputString } from "../utils/normalization";
import { addEquivalentsAndSimplify } from "../quantities/alternatives";
import {
multiplyQuantityValue,
Expand Down Expand Up @@ -775,8 +776,7 @@ export class Recipe {
// Type for accumulated quantities
type QuantityAccumulator = {
quantities: (
| QuantityWithExtendedUnit
| FlatOrGroup<QuantityWithExtendedUnit>
QuantityWithExtendedUnit | FlatOrGroup<QuantityWithExtendedUnit>
)[];
alternativeQuantities: Map<
number,
Expand Down Expand Up @@ -1199,8 +1199,7 @@ export class Recipe {

// Collect all raw quantities across all signature groups
const quantities: (
| QuantityWithExtendedUnit
| FlatOrGroup<QuantityWithExtendedUnit>
QuantityWithExtendedUnit | FlatOrGroup<QuantityWithExtendedUnit>
)[] = [];

if (usedAsPrimary) {
Expand Down Expand Up @@ -1287,8 +1286,7 @@ export class Recipe {
const groupsForIng = ingredientGroups.get(index);
if (groupsForIng) {
const quantityGroups: (
| IngredientQuantityGroup
| IngredientQuantityAndGroup
IngredientQuantityGroup | IngredientQuantityAndGroup
)[] = [];

for (const [, group] of groupsForIng) {
Expand Down Expand Up @@ -1421,10 +1419,12 @@ export class Recipe {
*/
parse(content: string) {
// Remove noise
const cleanContent = content
.replace(metadataRegex, "")
.replace(commentRegex, "")
.replace(blockCommentRegex, "")
const cleanContent = normalizeInputString(
content
.replace(metadataRegex, "")
.replace(commentRegex, "")
.replace(blockCommentRegex, ""),
)
.trim()
.split(/\r\n?|\n/);

Expand Down
8 changes: 4 additions & 4 deletions src/classes/shopping_list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import { parseQuantityWithUnit } from "../utils/parser_helpers";
import { formatQuantity } from "../utils/render_helpers";
import { NoTabAsIndentError, UnknownRecipePathError } from "../errors";
import { normalizeInputString } from "../utils/normalization";

/**
* Shopping List generator.
Expand Down Expand Up @@ -206,8 +207,7 @@ export class ShoppingList {
// Separate text-value quantities (cannot be summed) from numeric ones
const textEntries: QuantityWithExtendedUnit[] = [];
const numericEntries: (
| QuantityWithExtendedUnit
| FlatOrGroup<QuantityWithExtendedUnit>
QuantityWithExtendedUnit | FlatOrGroup<QuantityWithExtendedUnit>
)[] = [];
for (const q of rawQuantities) {
if (
Expand Down Expand Up @@ -1058,7 +1058,7 @@ export class ShoppingList {
*/
private parseRecipeRefLine(line: string): ShoppingListRecipeRef {
// this function is always called for lines starting with "./" so regex always matches
const match = line.match(recipeRefLineRegex)!;
const match = normalizeInputString(line).match(recipeRefLineRegex)!;
return {
path: match[1]!,
servings: match[2] ? Number(match[2]) : undefined,
Expand All @@ -1070,7 +1070,7 @@ export class ShoppingList {
* @internal
*/
private parseManualItemLine(line: string): AddedIngredient {
const match = line.match(manualIngredientRegex);
const match = normalizeInputString(line).match(manualIngredientRegex);
const groups = match!.groups as { name: string; quantity?: string };
const name = groups.name.trim();
if (groups.quantity) {
Expand Down
36 changes: 36 additions & 0 deletions src/utils/normalization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const fullWidthSyntaxMap: Record<string, string> = {
"@": "@",
"#": "#",
"~": "~",
"〜": "~",
"{": "{",
"}": "}",
"(": "(",
")": ")",
"[": "[",
"]": "]",
"%": "%",
"|": "|",
":": ":",
",": ",",
".": ".",
"/": "/",
"-": "-",
"=": "=",
"\u3000": " ",
};

/**
* Normalizes parser-significant full-width characters without changing prose.
*/
export function normalizeInputString(input: string): string {
return input.replace(
/[@#~〜{}()[]%|:,./-=\u30000-9]/g,
(char) => {
if (char >= "0" && char <= "9") {
return String(char.charCodeAt(0) - "0".charCodeAt(0));
}
return fullWidthSyntaxMap[char]!;
},
);
}
43 changes: 25 additions & 18 deletions src/utils/parser_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
ReferencedItemCannotBeRedefinedError,
} from "../errors";
import { parseTimeToMinutes } from "./time";
import { normalizeInputString } from "./normalization";

/**
* Pushes a pending note to the section content if it has items.
Expand Down Expand Up @@ -250,12 +251,13 @@ export function findAndUpsertCookware(
export const parseFixedValue = (
input_str: string,
): TextValue | DecimalValue | FractionValue => {
if (!numberLikeRegex.test(input_str)) {
return { type: "text", text: input_str };
const normalizedInput = normalizeInputString(input_str);
if (!numberLikeRegex.test(normalizedInput)) {
return { type: "text", text: normalizedInput };
}

// After this we know that s is either a fraction or a decimal value
const s = input_str.trim().replace(",", ".");
const s = normalizedInput.trim().replace(",", ".");

// fraction
if (s.includes("/")) {
Expand Down Expand Up @@ -323,7 +325,7 @@ function stringifyFixedValue(quantity: FixedValue): string {
* ```
*/
export function parseQuantityValue(input_str: string): FixedValue | Range {
const clean_str = String(input_str).trim();
const clean_str = normalizeInputString(String(input_str)).trim();

if (rangeRegex.test(clean_str)) {
const range_parts = clean_str.split("-");
Expand Down Expand Up @@ -355,7 +357,7 @@ export function parseQuantityWithUnit(input: string): {
value: FixedValue | Range;
unit?: string;
} {
const trimmed = input.trim();
const trimmed = normalizeInputString(input).trim();
const separatorIndex = trimmed.indexOf("%");
if (separatorIndex === -1) {
return { value: parseQuantityValue(trimmed) };
Expand Down Expand Up @@ -677,19 +679,20 @@ export function parseBlockScalarMetaVar(
* @throws {@link InvalidQuantityFormat} if the value is non-numeric.
*/
export function parseArbitraryQuantity(raw: string): ArbitraryScalable {
const quantityMatch = raw.trim().match(quantityAlternativeRegex);
const normalizedRaw = normalizeInputString(raw);
const quantityMatch = normalizedRaw.trim().match(quantityAlternativeRegex);
/* v8 ignore next 4 -- @preserve: defensive guard; regex always matches */
if (!quantityMatch?.groups) {
throw new InvalidQuantityFormat(
raw,
normalizedRaw,
"Arbitrary quantities must have a numerical value",
);
}
const value = parseQuantityValue(quantityMatch.groups.quantity!);
const unit = quantityMatch.groups.unit;
if (!value || (value.type === "fixed" && value.value.type === "text")) {
throw new InvalidQuantityFormat(
raw,
normalizedRaw,
"Arbitrary quantities must have a numerical value",
);
}
Expand All @@ -713,11 +716,12 @@ export function parseServingsMetaVar(
content: string,
varName: "servings" | "serves",
): { numericValue: number; rawValue: number | string } | undefined {
const raw = parseSimpleMetaVar(content, varName);
const raw = parseSimpleMetaVar(normalizeInputString(content), varName);
if (raw === undefined) return undefined;
const num = Number(raw);
const normalizedRaw = normalizeInputString(String(raw));
const num = Number(normalizedRaw);
if (isNaN(num)) {
return { numericValue: 1, rawValue: raw };
return { numericValue: 1, rawValue: normalizedRaw };
}
return { numericValue: num, rawValue: num };
}
Expand All @@ -733,7 +737,7 @@ export function parseServingsMetaVar(
* @throws {@link InvalidQuantityFormat} if the value is non-numeric.
*/
export function parseYieldMetaVar(content: string): Yield | undefined {
const match = content.match(yieldMetaValueRegex);
const match = normalizeInputString(content).match(yieldMetaValueRegex);
if (!match) return undefined;

// Complex format branch: matched the {{...}} pattern
Expand Down Expand Up @@ -1006,21 +1010,22 @@ function parseListItems(
* Parses a raw string value into appropriate type (number, string, or array).
*/
function parseSingleLineMetadataValue(rawValue: string): MetadataValue {
const normalizedRawValue = normalizeInputString(rawValue);
// Check for inline array [a, b, c]
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
return rawValue
if (normalizedRawValue.startsWith("[") && normalizedRawValue.endsWith("]")) {
return normalizedRawValue
.slice(1, -1)
.split(",")
.map((item) => item.trim());
}

// Check for number (integer or decimal)
if (numericValueRegex.test(rawValue)) {
return Number(rawValue);
if (numericValueRegex.test(normalizedRawValue)) {
return Number(normalizedRawValue);
}

// Return as string
return rawValue;
return normalizedRawValue;
}

/**
Expand Down Expand Up @@ -1063,7 +1068,9 @@ export function extractMetadata(content: string): MetadataExtract {
let servings: number | undefined = undefined;

// Is there front-matter at all?
const metadataContent = content.match(metadataRegex)?.[2];
const metadataContent = normalizeInputString(
content.match(metadataRegex)?.[2] ?? "",
);
if (!metadataContent) {
return { metadata };
}
Expand Down
3 changes: 2 additions & 1 deletion src/utils/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { compactTimeRegex, timeUnitTokenRegex } from "../regex";
import { normalizeInputString } from "./normalization";

/** Maps unit strings to their value in minutes. */
const timeUnitToMinutes: Record<string, number> = {
Expand Down Expand Up @@ -60,7 +61,7 @@ export function parseTimeToMinutes(input: string | number): number | undefined {
return input;
}

const trimmed = input.trim();
const trimmed = normalizeInputString(input).trim();
if (trimmed === "") return undefined;

// Strategy 1: Plain number string → minutes
Expand Down
57 changes: 57 additions & 0 deletions test/parser_helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ describe("parseServingsMetaVar", () => {
rawValue: 6,
});
});
it("should parse full-width separators and digits", () => {
expect(parseServingsMetaVar("servings:6", "servings")).toEqual({
numericValue: 6,
rawValue: 6,
});
});
it("should parse serves", () => {
expect(parseServingsMetaVar("serves: 4", "serves")).toEqual({
numericValue: 4,
Expand Down Expand Up @@ -107,6 +113,12 @@ describe("parseYieldMetaVar", () => {
unit: "g",
});
});
it("should parse full-width quantity punctuation and digits", () => {
expect(parseYieldMetaVar("yield:300%g")).toEqual<Yield>({
quantity: { type: "fixed", value: { type: "decimal", decimal: 300 } },
unit: "g",
});
});
it("should parse plain fraction quantity with unit", () => {
expect(parseYieldMetaVar("yield: 1/2%kg")).toEqual<Yield>({
quantity: { type: "fixed", value: { type: "fraction", num: 1, den: 2 } },
Expand Down Expand Up @@ -160,6 +172,12 @@ describe("parseArbitraryQuantity", () => {
unit: "g",
});
});
it("should parse full-width quantity punctuation and digits", () => {
expect(parseArbitraryQuantity("300%g")).toEqual({
quantity: { type: "fixed", value: { type: "decimal", decimal: 300 } },
unit: "g",
});
});
it("should parse a quantity without unit", () => {
expect(parseArbitraryQuantity("5")).toEqual({
quantity: { type: "fixed", value: { type: "decimal", decimal: 5 } },
Expand Down Expand Up @@ -465,6 +483,27 @@ describe("extractMetadata", () => {
expect(extractMetadata(content)).toEqual({ metadata: {} });
});

it("should parse full-width separators and digits in metadata", () => {
const content = `---
servings:2
yield:300%g
time:1時間 30分
---`;
expect(extractMetadata(content)).toEqual<MetadataExtract>({
metadata: {
servings: 2,
yield: {
quantity: { type: "fixed", value: { type: "decimal", decimal: 300 } },
unit: "g",
},
time: {
total: 90,
},
},
servings: 2,
});
});

it("should return an empty object if metavars are declared outside of block", () => {
const content = `
---
Expand Down Expand Up @@ -1077,6 +1116,11 @@ describe("parseQuantityValue", () => {
min: { type: "fraction", num: 1, den: 2 },
max: { type: "decimal", decimal: 1 },
});
expect(parseQuantityValue("1-2")).toEqual({
type: "range",
min: { type: "decimal", decimal: 1 },
max: { type: "decimal", decimal: 2 },
});
});

it("correctly parses fixed values", () => {
Expand All @@ -1088,6 +1132,10 @@ describe("parseQuantityValue", () => {
type: "fixed",
value: { type: "decimal", decimal: 1.2 },
});
expect(parseQuantityValue("1.2")).toEqual({
type: "fixed",
value: { type: "decimal", decimal: 1.2 },
});
});
});

Expand Down Expand Up @@ -1612,6 +1660,15 @@ describe("parseQuantityWithUnit", () => {
expect(result.unit).toBe("g");
});

it("should parse full-width quantity punctuation and digits", () => {
const result = parseQuantityWithUnit("500%g");
expect(result.value).toMatchObject({
type: "fixed",
value: { type: "decimal", decimal: 500 },
});
expect(result.unit).toBe("g");
});

it("should parse value without unit", () => {
const result = parseQuantityWithUnit("6");
expect(result.value).toMatchObject({
Expand Down
Loading