Debug nested template arguments (#788)

This commit is contained in:
Gregory Schier authored and GitHub committed 2026-11-01 21:46:14 -07:00
1 parent c9ad753672
commit 87a30200f9
6 files changed
+557 -5

No files matched your search

@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { Workspace } from "@yaakapp-internal/models";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { Tokens } from "@yaakapp-internal/templates";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vite-plus/test";
import { TemplateFunctionDialog } from "./TemplateFunctionDialog";
interface FormProps {
data: Record<string, string | boolean>;
onChange: (values: Record<string, string | boolean>) => void;
}
const mocks = vi.hoisted(() => ({
parse: vi.fn<(text: string) => Tokens>(),
print: vi.fn<(tokens: Tokens) => Promise<string>>(),
serialize: vi.fn<(tokens: Tokens) => { data: string }>(),
preview: vi.fn<({ template }: { template: string }) => { data: string }>(),
form: null as FormProps | null,
config: {
name: "timestamp.format",
args: [
{ type: "text", name: "date" },
{ type: "text", name: "format" },
],
} as TemplateFunction,
}));
vi.mock("@yaakapp-internal/templates", () => ({ parseTemplate: mocks.parse }));
vi.mock("@yaakapp-internal/ui", () => ({
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
InlineCode: "code",
LoadingIcon: () => null,
useDebouncedValue: (value: string) => value,
}));
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceAtom: null }));
vi.mock("../hooks/useRenderTemplate", () => ({ useRenderTemplate: mocks.preview }));
vi.mock("../hooks/useTemplateFunctionConfig", () => ({
useTemplateFunctionConfig: () => ({ data: mocks.config }),
}));
vi.mock("../hooks/useTemplateTokensToString", () => ({
templateTokensToString: mocks.print,
useTemplateTokensToString: mocks.serialize,
}));
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
vi.mock("../lib/encryption", () => ({ convertTemplateToInsecure: vi.fn() }));
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
vi.mock("../lib/setupOrConfigureEncryption", () => ({ setupOrConfigureEncryption: vi.fn() }));
vi.mock("./core/Dialog", () => ({ DialogFooter: () => <button type="submit">Save</button> }));
vi.mock("./core/IconButton", () => ({ IconButton: () => <button type="button" /> }));
vi.mock("./core/PlainInput", () => ({ PlainInput: () => null }));
vi.mock("./DynamicForm", () => ({
DYNAMIC_FORM_NULL_ARG: "__NULL__",
DynamicForm: (props: FormProps) => {
mocks.form = props;
return <input aria-label="Timestamp" readOnly value={String(props.data.date ?? "")} />;
},
}));
beforeAll(async () => {
const base = pathToFileURL(
resolve(import.meta.dirname, "../../../crates/yaak-templates/pkg") + "/",
);
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
const { instance } = await WebAssembly.instantiate(
await readFile(new URL("yaak_templates_bg.wasm", base)),
{ "./yaak_templates_bg.js": bindings },
);
bindings.__wbg_set_wasm(instance.exports);
(instance.exports.__wbindgen_start as () => void)();
mocks.parse.mockImplementation(bindings.parse_template);
});
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
mocks.form = null;
// The transport returns an opaque string. Carry the AST as JSON so the assertions can
// inspect what Preview and Save receive, without duplicating the backend's printer.
mocks.serialize.mockImplementation((tokens) => ({ data: JSON.stringify(tokens) }));
mocks.preview.mockImplementation(({ template }) => ({ data: template }));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
function functionValue(tokens: Tokens) {
const first = tokens.tokens[0];
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected function");
return first.val;
}
test.each([
["timestamp.offset(expression='-1h')", "${[ timestamp.offset(expression='-1h') ]}"],
["date_var", "${[ date_var ]}"],
["dates.previous", "${[ dates.previous ]}"],
["'${[ date_var ]}'", "${[ date_var ]}"],
])("Preview and Save preserve %s until that input changes", async (expression, display) => {
const initialTokens = mocks.parse(`\${[ timestamp.format(date=${expression}, format='HH:mm') ]}`);
const originalDate = functionValue(initialTokens).args[0];
const saved = vi.fn<(value: string) => void>();
mocks.print.mockResolvedValue(display);
await act(async () =>
root.render(
<TemplateFunctionDialog
templateFunction={mocks.config}
initialTokens={initialTokens}
model={{ id: "wk_test" } as Workspace}
onChange={saved}
hide={() => {}}
/>,
),
);
expect(host.querySelector<HTMLInputElement>("input")?.value).toBe(display);
async function submit() {
const form = host.querySelector("form");
if (form == null) throw new Error("Dialog did not initialize");
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
const value = saved.mock.calls.at(-1)?.[0];
expect(value).toBe(mocks.preview.mock.calls.at(-1)?.[0].template);
return functionValue(JSON.parse(value ?? "null") as Tokens).args;
}
expect((await submit())[0]).toEqual(originalDate);
await act(async () => mocks.form?.onChange({ ...mocks.form.data, format: "yyyy" }));
expect(await submit()).toEqual([
originalDate,
{ name: "format", value: { type: "str", text: "yyyy" } },
]);
const edited = "${[ timestamp.offset(expression='-2h') ]}";
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: edited }));
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: edited } });
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: "" }));
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: "" } });
});
@@ -24,8 +24,11 @@ import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import {
collectEditableArgumentValues,
preserveUnchangedArgumentExpressions,
} from "../lib/templateFunctionArguments";
import { DialogFooter } from "./core/Dialog";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
@@ -48,7 +51,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
}
(async () => {
const initial = collectArgumentValues(initialTokens, templateFunction);
const initial = await collectEditableArgumentValues(
initialTokens,
templateFunction,
templateTokensToString,
);
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
// we can display it in the editor input.
@@ -74,6 +81,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
<InitializedTemplateFunctionDialog
{...props}
templateFunction={templateFunction}
initialTokens={initialTokens}
initialArgValues={initialArgValues}
/>
);
@@ -82,10 +90,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
function InitializedTemplateFunctionDialog({
templateFunction: { name, previewType: ogPreviewType },
initialArgValues,
initialTokens,
hide,
onChange,
model,
}: Omit<Props, "initialTokens"> & {
}: Props & {
initialArgValues: Record<string, string | boolean>;
}) {
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
@@ -111,12 +120,17 @@ function InitializedTemplateFunctionDialog({
val: {
type: "fn",
name,
args: argTokens,
args: preserveUnchangedArgumentExpressions(
argTokens,
initialTokens,
initialArgValues,
argValues,
),
},
},
],
};
}, [argValues, name]);
}, [argValues, initialArgValues, initialTokens, name]);
const tagText = useTemplateTokensToString(tokens);
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
@@ -0,0 +1,263 @@
import { readFile } from "node:fs/promises";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { beforeAll, describe, expect, test, vi } from "vite-plus/test";
import {
collectEditableArgumentValues,
preserveUnchangedArgumentExpressions,
} from "./templateFunctionArguments";
// Use the shipped WASM parser without the browser-only `?init` loader.
let parse: (text: string) => Tokens;
beforeAll(async () => {
const base = new URL("../../../crates/yaak-templates/pkg/", import.meta.url);
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
const { instance } = await WebAssembly.instantiate(
await readFile(new URL("yaak_templates_bg.wasm", base)),
{ "./yaak_templates_bg.js": bindings },
);
bindings.__wbg_set_wasm(instance.exports);
(instance.exports.__wbindgen_start as () => void)();
parse = bindings.parse_template;
});
const definition: TemplateFunction = {
name: "timestamp.iso8601",
args: [{ type: "text", name: "date", optional: true }],
};
function args(tokens: Tokens): FnArg[] {
const first = tokens.tokens[0];
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected a function");
return first.val.args;
}
function stringArg(name: string, text: string): FnArg {
return { name, value: { type: "str", text } };
}
// Printing is an existing async RPC. Assert the exact expression handed to it and return
// its expected text, including real parser round-tripping of quoted/escaped fixtures.
function printer(text: string) {
return vi.fn(async (tokens: Tokens) => {
expect(tokens.tokens).toEqual(parse(text).tokens.filter((t) => t.type !== "eof"));
return text;
});
}
describe("editable template function arguments", () => {
test.each([
[
"reported call",
"${[timestamp.iso8601(date=timestamp.offset(expression='-1h')) ]}",
"${[ timestamp.offset(expression='-1h') ]}",
],
["variable", "${[ timestamp.iso8601(date=date_var) ]}", "${[ date_var ]}"],
["dotted variable", "${[ timestamp.iso8601(date=dates.previous) ]}", "${[ dates.previous ]}"],
[
"nested escaping",
String.raw`${"${["} timestamp.iso8601(date=outer(value=inner(text='it\'s \\ nested'))) ]}`,
String.raw`${"${["} outer(value=inner(text='it\'s \\ nested')) ]}`,
],
])("loads and preserves %s on unchanged Save", async (_, source, display) => {
const initialTokens = parse(source);
const snapshot = structuredClone(initialTokens);
const print = printer(display);
const initial = await collectEditableArgumentValues(initialTokens, definition, print);
expect(initial).toEqual({ date: display });
expect(print).toHaveBeenCalledOnce();
const saved = preserveUnchangedArgumentExpressions(
[stringArg("date", display)],
initialTokens,
initial,
{ ...initial },
);
expect(saved).toEqual(args(initialTokens));
expect(initialTokens).toEqual(snapshot);
});
test.each([
String.raw`${"${["} timestamp.iso8601(date='${"${["} timestamp.offset(expression=\'-1h\') ]}') ]}`,
"${[ timestamp.iso8601(date='${[ date_var ]}') ]}",
"${[ timestamp.iso8601(date='${[ dates.previous ]}') ]}",
"${[ timestamp.iso8601(date='2026-10-27T11:00:00.000Z') ]}",
])("leaves existing quoted arguments unchanged: %s", async (source) => {
const initialTokens = parse(source);
const print = vi.fn<(_: Tokens) => Promise<string>>();
const initial = await collectEditableArgumentValues(initialTokens, definition, print);
const value = args(initialTokens)[0]?.value;
if (value?.type !== "str") throw new Error("Expected a string fixture");
expect(initial).toEqual({ date: value.text });
expect(print).not.toHaveBeenCalled();
expect(
preserveUnchangedArgumentExpressions(
[stringArg("date", value.text)],
initialTokens,
initial,
initial,
),
).toEqual(args(initialTokens));
});
test("editing a sibling preserves the original expression", async () => {
const initialTokens = parse(
"${[ timestamp.format(date=timestamp.offset(expression='-1h'), format='HH:mm') ]}",
);
const initial = await collectEditableArgumentValues(
initialTokens,
{
name: "timestamp.format",
args: [...definition.args, { type: "text", name: "format" }],
},
printer("${[ timestamp.offset(expression='-1h') ]}"),
);
const values: Record<string, string | boolean> = { ...initial, format: "yyyy-MM-dd" };
const saved = preserveUnchangedArgumentExpressions(
[stringArg("date", String(values.date)), stringArg("format", String(values.format))],
initialTokens,
initial,
values,
);
expect(saved).toEqual([args(initialTokens)[0], stringArg("format", "yyyy-MM-dd")]);
});
test.each(["${[ timestamp.offset(expression='-2h') ]}", "2026-10-26", ""])(
"intentional edits use the form's quoted-string convention: %s",
async (edited) => {
const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}");
const initial = await collectEditableArgumentValues(
initialTokens,
definition,
printer("${[ date_var ]}"),
);
const changed = [stringArg("date", edited)];
expect(
preserveUnchangedArgumentExpressions(changed, initialTokens, initial, { date: edited }),
).toEqual(changed);
// Returning the input to its original text restores the original expression type.
expect(
preserveUnchangedArgumentExpressions(
[stringArg("date", String(initial.date))],
initialTokens,
initial,
{ ...initial },
),
).toEqual(args(initialTokens));
},
);
test("does not resurrect removed or explicitly cleared expression arguments", async () => {
const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}");
const initial = await collectEditableArgumentValues(
initialTokens,
definition,
printer("${[ date_var ]}"),
);
expect(preserveUnchangedArgumentExpressions([], initialTokens, initial, {})).toEqual([]);
const nullArg: FnArg = { name: "date", value: { type: "null" } };
expect(
preserveUnchangedArgumentExpressions([nullArg], initialTokens, initial, { date: "__NULL__" }),
).toEqual([nullArg]);
// DynamicForm clears some inputs by leaving a key with undefined; the dialog turns it into ''.
expect(
preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, initial, {}),
).toEqual([stringArg("date", "")]);
expect(
preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, {}, {}),
).toEqual([stringArg("date", "")]);
});
test("keeps absence, defaults, explicit empty strings, and false distinct", async () => {
const fn: TemplateFunction = {
name: "test",
args: [
{ type: "text", name: "absent" },
{ type: "text", name: "defaulted", defaultValue: "fallback" },
{ type: "text", name: "empty", defaultValue: "fallback" },
{ type: "checkbox", name: "flag", defaultValue: "true" },
{ type: "checkbox", name: "defaultFlag", defaultValue: "false" },
],
};
const initialTokens = parse("${[ test(empty='', flag=false) ]}");
const print = vi.fn<(_: Tokens) => Promise<string>>();
const initial = await collectEditableArgumentValues(initialTokens, fn, print);
expect(initial).toEqual({
defaulted: "fallback",
empty: "",
flag: false,
defaultFlag: "false",
});
expect(print).not.toHaveBeenCalled();
const formArgs: FnArg[] = [
stringArg("defaulted", "fallback"),
stringArg("empty", ""),
{ name: "flag", value: { type: "bool", value: false } },
stringArg("defaultFlag", "false"),
];
expect(preserveUnchangedArgumentExpressions(formArgs, initialTokens, initial, initial)).toEqual(
formArgs,
);
});
test("an expression overrides defaults, including inputs in a group", async () => {
const initialTokens = parse("${[ test(date=dates.previous) ]}");
const initial = await collectEditableArgumentValues(
initialTokens,
{
name: "test",
args: [
{ type: "h_stack", inputs: [{ type: "text", name: "date", defaultValue: "fallback" }] },
],
},
printer("${[ dates.previous ]}"),
);
expect(initial).toEqual({ date: "${[ dates.previous ]}" });
});
test("explicit null keeps the existing default and omission behavior", async () => {
const initialTokens = parse("${[ test(defaulted=null, omitted=null) ]}");
const print = vi.fn<(_: Tokens) => Promise<string>>();
const initial = await collectEditableArgumentValues(
initialTokens,
{
name: "test",
args: [
{ type: "text", name: "defaulted", defaultValue: "fallback" },
{ type: "text", name: "omitted" },
],
},
print,
);
expect(initial).toEqual({ defaulted: "fallback" });
expect(print).not.toHaveBeenCalled();
expect(
preserveUnchangedArgumentExpressions(
[stringArg("defaulted", "fallback")],
initialTokens,
initial,
initial,
),
).toEqual([stringArg("defaulted", "fallback")]);
});
test("keeps a deeply nested expression's AST without adding string wrappers", async () => {
// This shape reaches the native renderer's depth limit. A quoted wrapper around
// the date expression would exceed it, even though shallow calls render identically.
const expression =
"timestamp.iso8601(date=".repeat(44) + "'2026-10-26T10:20:30.000Z'" + ")".repeat(44);
const initialTokens = parse(`\${[ timestamp.iso8601(date=${expression}) ]}`);
const initial = await collectEditableArgumentValues(
initialTokens,
definition,
printer(`\${[ ${expression} ]}`),
);
const saved = preserveUnchangedArgumentExpressions(
[stringArg("date", String(initial.date))],
initialTokens,
initial,
initial,
);
expect(saved).toEqual(args(initialTokens));
});
});
@@ -0,0 +1,59 @@
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { collectArgumentValues } from "../components/core/Editor/twig/util";
type ArgumentValues = Record<string, string | boolean>;
function functionArgs(tokens: Tokens): FnArg[] {
const first = tokens.tokens[0];
return first?.type === "tag" && first.val.type === "fn" ? first.val.args : [];
}
/** Adapt expressions to the template text that the form's editors understand. */
export async function collectEditableArgumentValues(
tokens: Tokens,
templateFunction: TemplateFunction,
tokensToString: (tokens: Tokens) => Promise<string>,
): Promise<ArgumentValues> {
const first = tokens.tokens[0];
if (first?.type !== "tag" || first.val.type !== "fn") {
return collectArgumentValues(tokens, templateFunction);
}
const args = await Promise.all(
first.val.args.map(async (arg): Promise<FnArg> => {
if (arg.value.type !== "fn" && arg.value.type !== "var") return arg;
const text = await tokensToString({ tokens: [{ type: "tag", val: arg.value }] });
return { ...arg, value: { type: "str", text } };
}),
);
return collectArgumentValues(
{ tokens: [{ ...first, val: { ...first.val, args } }] },
templateFunction,
);
}
/**
* Displaying an expression as template text must not change its stored type: a string
* wrapper adds rendering depth. Only deliberately changed inputs use the form's new values.
*/
export function preserveUnchangedArgumentExpressions(
args: FnArg[],
initialTokens: Tokens,
initialValues: ArgumentValues,
values: ArgumentValues,
): FnArg[] {
const initialArgs = functionArgs(initialTokens);
return args.map((arg) => {
const initial = initialArgs.find((a) => a.name === arg.name);
if (
(initial?.value.type === "fn" || initial?.value.type === "var") &&
Object.hasOwn(initialValues, arg.name) &&
values[arg.name] === initialValues[arg.name]
) {
return initial;
}
return arg;
});
}
+1
View File
@@ -93,6 +93,7 @@
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"happy-dom": "^21.0.4",
"rollup": "^4.66.0",
"vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0",
"vite-plugin-static-copy": "^4.1.1",
+63
View File
@@ -190,6 +190,7 @@
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"happy-dom": "^21.0.4",
"rollup": "^4.66.0",
"vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0",
"vite-plugin-static-copy": "^4.1.1",
@@ -6043,6 +6044,19 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -6948,6 +6962,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -7945,6 +7972,42 @@
"graphql": "^15.5.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/happy-dom": {
"version": "21.0.4",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.4.tgz",
"integrity": "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/happy-dom/node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/happy-dom/node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",