mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-17 00:30:05 +10:00
fix: resolve multi-page PDF crashes and Gemini API ingestion errors (#2781)
* fix: resolve multi-page PDF crashes and Gemini API ingestion errors * fix type errors * refactor: address PR review feedback and prevent call stack recursion in regex payload scanner --------- Co-authored-by: Ofir <ofir@example.com> Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
+11
-7
@@ -62,12 +62,12 @@
|
||||
"@tanstack/react-router-ssr-query": "^1.166.2",
|
||||
"@tanstack/react-start": "^1.166.2",
|
||||
"@tanstack/zod-adapter": "^1.166.2",
|
||||
"@tiptap/extension-highlight": "^3.20.0",
|
||||
"@tiptap/extension-table": "^3.20.0",
|
||||
"@tiptap/extension-text-align": "^3.20.0",
|
||||
"@tiptap/pm": "^3.20.0",
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"@tiptap/extension-highlight": "^3.20.1",
|
||||
"@tiptap/extension-table": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/react": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"ai": "^6.0.116",
|
||||
"ai-sdk-ollama": "^3.8.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -85,9 +85,12 @@
|
||||
"immer": "^11.1.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsonrepair": "^3.13.2",
|
||||
"mammoth": "^1.11.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"motion": "^12.35.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.20.0",
|
||||
"puppeteer-core": "^24.38.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@@ -156,7 +159,8 @@
|
||||
"onlyBuiltDependencies": [
|
||||
"bcrypt",
|
||||
"esbuild",
|
||||
"sharp"
|
||||
"sharp",
|
||||
"pdf-parse"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+502
-207
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -63,7 +64,21 @@ const formSchema = z.discriminatedUnion("type", [
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// remove data URL prefix (e.g., "data:application/pdf;base64," or "data:application/vnd...;base64,")
|
||||
resolve(result.split(",")[1]);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
const navigate = useNavigate();
|
||||
const { enabled: isAIEnabled, provider, model, apiKey, baseURL } = useAIStore();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
@@ -135,8 +150,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
if (!isAIEnabled)
|
||||
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
|
||||
|
||||
const arrayBuffer = await values.file.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
// const arrayBuffer = await values.file.arrayBuffer();
|
||||
const base64 = await fileToBase64(values.file);
|
||||
|
||||
data = await client.ai.parsePdf({
|
||||
provider,
|
||||
@@ -151,8 +166,9 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
if (!isAIEnabled)
|
||||
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
|
||||
|
||||
const arrayBuffer = await values.file.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
// const arrayBuffer = await values.file.arrayBuffer();
|
||||
const base64 = await fileToBase64(values.file);
|
||||
|
||||
const mediaType =
|
||||
values.file.type === "application/msword"
|
||||
? ("application/msword" as const)
|
||||
@@ -170,9 +186,10 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
|
||||
if (!data) throw new Error("No data was returned from the AI provider.");
|
||||
|
||||
await importResume({ data });
|
||||
const id = await importResume({ data });
|
||||
toast.success(t`Your resume has been imported successfully.`, { id: toastId, description: null });
|
||||
closeDialog();
|
||||
navigate({ to: `/builder/$resumeId`, params: { resumeId: id } });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message, { id: toastId, description: null });
|
||||
|
||||
@@ -2,10 +2,10 @@ import { ORPCError } from "@orpc/client";
|
||||
import { type } from "@orpc/server";
|
||||
import { AISDKError, type UIMessage } from "ai";
|
||||
import { OllamaError } from "ai-sdk-ollama";
|
||||
import z, { ZodError } from "zod";
|
||||
import z, { flattenError, ZodError } from "zod";
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema, formatZodError } from "../services/ai";
|
||||
import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema } from "../services/ai";
|
||||
|
||||
type AIProvider = z.infer<typeof aiProviderSchema>;
|
||||
|
||||
@@ -69,6 +69,10 @@ export const aiRouter = {
|
||||
message: "The AI provider returned an error or is unreachable.",
|
||||
status: 502,
|
||||
},
|
||||
BAD_REQUEST: {
|
||||
message: "The AI returned an improperly formatted structure.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ input }): Promise<ResumeData> => {
|
||||
try {
|
||||
@@ -79,7 +83,10 @@ export const aiRouter = {
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
throw new Error(formatZodError(error));
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -111,6 +118,10 @@ export const aiRouter = {
|
||||
message: "The AI provider returned an error or is unreachable.",
|
||||
status: 502,
|
||||
},
|
||||
BAD_REQUEST: {
|
||||
message: "The AI returned an improperly formatted structure.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
@@ -121,7 +132,10 @@ export const aiRouter = {
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
throw new Error(formatZodError(error));
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { extractRawText } from "mammoth";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
|
||||
export async function extractPdfText(base64Data: string): Promise<string> {
|
||||
const pdfParse = new PDFParse({ data: base64Data });
|
||||
const data = await pdfParse.getText();
|
||||
return data.text;
|
||||
}
|
||||
|
||||
export async function extractDocxText(base64Data: string): Promise<string> {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const data = await extractRawText({ buffer });
|
||||
return data.value;
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
type UIMessage,
|
||||
} from "ai";
|
||||
import { createOllama } from "ai-sdk-ollama";
|
||||
import { jsonrepair } from "jsonrepair";
|
||||
import { match } from "ts-pattern";
|
||||
import type { ZodError } from "zod";
|
||||
import z, { flattenError } from "zod";
|
||||
import z, { flattenError, ZodError } from "zod";
|
||||
import chatSystemPromptTemplate from "@/integrations/ai/prompts/chat-system.md?raw";
|
||||
import docxParserSystemPrompt from "@/integrations/ai/prompts/docx-parser-system.md?raw";
|
||||
import docxParserUserPrompt from "@/integrations/ai/prompts/docx-parser-user.md?raw";
|
||||
@@ -28,6 +28,204 @@ import {
|
||||
} from "@/integrations/ai/tools/patch-resume";
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
import { isObject } from "@/utils/sanitize";
|
||||
import { extractDocxText, extractPdfText } from "./ai.server";
|
||||
|
||||
const aiExtractionTemplate = {
|
||||
...defaultResumeData,
|
||||
basics: {
|
||||
...defaultResumeData.basics,
|
||||
customFields: [{ id: "", icon: "", text: "", link: "" }],
|
||||
},
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
profiles: {
|
||||
...defaultResumeData.sections.profiles,
|
||||
items: [{ id: "", hidden: false, icon: "", network: "", username: "", website: { url: "", label: "" } }],
|
||||
},
|
||||
experience: {
|
||||
...defaultResumeData.sections.experience,
|
||||
items: [
|
||||
{
|
||||
id: "",
|
||||
hidden: false,
|
||||
company: "",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
education: {
|
||||
...defaultResumeData.sections.education,
|
||||
items: [
|
||||
{
|
||||
id: "",
|
||||
hidden: false,
|
||||
school: "",
|
||||
degree: "",
|
||||
area: "",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
projects: {
|
||||
...defaultResumeData.sections.projects,
|
||||
items: [{ id: "", hidden: false, name: "", period: "", website: { url: "", label: "" }, description: "" }],
|
||||
},
|
||||
skills: {
|
||||
...defaultResumeData.sections.skills,
|
||||
items: [{ id: "", hidden: false, icon: "", name: "", proficiency: "", level: 0, keywords: [] }],
|
||||
},
|
||||
languages: {
|
||||
...defaultResumeData.sections.languages,
|
||||
items: [{ id: "", hidden: false, language: "", fluency: "", level: 0 }],
|
||||
},
|
||||
interests: {
|
||||
...defaultResumeData.sections.interests,
|
||||
items: [{ id: "", hidden: false, icon: "", name: "", keywords: [] }],
|
||||
},
|
||||
awards: {
|
||||
...defaultResumeData.sections.awards,
|
||||
items: [
|
||||
{ id: "", hidden: false, title: "", awarder: "", date: "", website: { url: "", label: "" }, description: "" },
|
||||
],
|
||||
},
|
||||
certifications: {
|
||||
...defaultResumeData.sections.certifications,
|
||||
items: [
|
||||
{ id: "", hidden: false, title: "", issuer: "", date: "", website: { url: "", label: "" }, description: "" },
|
||||
],
|
||||
},
|
||||
publications: {
|
||||
...defaultResumeData.sections.publications,
|
||||
items: [
|
||||
{ id: "", hidden: false, title: "", publisher: "", date: "", website: { url: "", label: "" }, description: "" },
|
||||
],
|
||||
},
|
||||
volunteer: {
|
||||
...defaultResumeData.sections.volunteer,
|
||||
items: [
|
||||
{
|
||||
id: "",
|
||||
hidden: false,
|
||||
organization: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
references: {
|
||||
...defaultResumeData.sections.references,
|
||||
items: [
|
||||
{ id: "", hidden: false, name: "", position: "", website: { url: "", label: "" }, phone: "", description: "" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Merges two objects recursively, filling in missing properties in the target object
|
||||
* with values from the source object, but does not overwrite existing properties in the target
|
||||
* unless the source provides a defined, non-null value.
|
||||
*
|
||||
* Both target and source must be plain objects (Record<string, unknown>).
|
||||
* This function does not mutate either argument; returns a new object.
|
||||
*
|
||||
* @param target - The object to merge into (existing values take precedence)
|
||||
* @param source - The object providing default values
|
||||
* @returns The merged object
|
||||
*/
|
||||
function mergeDefaults<T extends Record<string, unknown>, S extends Record<string, unknown>>(
|
||||
target: T,
|
||||
source: S,
|
||||
): T & S {
|
||||
if (!isObject(target) || !isObject(source)) {
|
||||
// Use source value if defined (non-null, non-undefined), else fallback to target
|
||||
return (source !== undefined && source !== null ? source : target) as T & S;
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = { ...target };
|
||||
|
||||
for (const key of Object.keys(source)) {
|
||||
const sourceValue = source[key];
|
||||
if (sourceValue === undefined || sourceValue === null) {
|
||||
continue;
|
||||
}
|
||||
const targetValue = target[key];
|
||||
|
||||
if (isObject(sourceValue) && isObject(targetValue)) {
|
||||
output[key] = mergeDefaults(targetValue as Record<string, unknown>, sourceValue as Record<string, unknown>);
|
||||
} else if (isObject(sourceValue) && (targetValue === undefined || targetValue === null)) {
|
||||
// Fill with source object only if target does not have it
|
||||
output[key] = sourceValue;
|
||||
} else if (!isObject(sourceValue)) {
|
||||
output[key] = sourceValue;
|
||||
} else if (targetValue === undefined) {
|
||||
output[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
return output as T & S;
|
||||
}
|
||||
|
||||
function logAndRethrow(context: string, error: unknown): never {
|
||||
if (error instanceof Error) {
|
||||
console.error(`${context}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Unknown error in ${context}:`, error);
|
||||
throw new Error(`An unknown error occurred during ${context}.`);
|
||||
}
|
||||
|
||||
function parseAndValidateResumeJson(resultText: string): ResumeData {
|
||||
let jsonString = resultText;
|
||||
const firstCurly = jsonString.indexOf("{");
|
||||
const firstSquare = jsonString.indexOf("[");
|
||||
const lastCurly = jsonString.lastIndexOf("}");
|
||||
const lastSquare = jsonString.lastIndexOf("]");
|
||||
|
||||
let firstIndex = -1;
|
||||
if (firstCurly !== -1 && firstSquare !== -1) {
|
||||
firstIndex = Math.min(firstCurly, firstSquare);
|
||||
} else {
|
||||
firstIndex = Math.max(firstCurly, firstSquare);
|
||||
}
|
||||
const lastIndex = Math.max(lastCurly, lastSquare);
|
||||
|
||||
if (firstIndex !== -1 && lastIndex !== -1 && lastIndex >= firstIndex) {
|
||||
jsonString = jsonString.substring(firstIndex, lastIndex + 1);
|
||||
}
|
||||
|
||||
try {
|
||||
const repairedJson = jsonrepair(jsonString);
|
||||
const parsedJson = JSON.parse(repairedJson);
|
||||
const mergedData = mergeDefaults(defaultResumeData, parsedJson);
|
||||
|
||||
return resumeDataSchema.parse({
|
||||
...mergedData,
|
||||
customSections: [],
|
||||
picture: defaultResumeData.picture,
|
||||
metadata: defaultResumeData.metadata,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ZodError) {
|
||||
console.error("Zod Validation Errors:", JSON.stringify(flattenError(error), null, 2));
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error("Unknown error:", error);
|
||||
throw new Error("An unknown error occurred while validating the merged resume data.");
|
||||
}
|
||||
}
|
||||
|
||||
export const aiProviderSchema = z.enum(["ollama", "openai", "gemini", "anthropic", "vercel-ai-gateway"]);
|
||||
|
||||
@@ -89,35 +287,28 @@ type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
|
||||
const model = getModel(input);
|
||||
|
||||
const pdfText = await extractPdfText(input.file.data).catch((error: unknown) =>
|
||||
logAndRethrow("Failed to parse PDF locally", error),
|
||||
);
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
output: Output.object({ schema: resumeDataSchema }),
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: pdfParserSystemPrompt,
|
||||
content:
|
||||
pdfParserSystemPrompt +
|
||||
"\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n" +
|
||||
JSON.stringify(aiExtractionTemplate, null, 2),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: pdfParserUserPrompt },
|
||||
{
|
||||
type: "file",
|
||||
filename: input.file.name,
|
||||
mediaType: "application/pdf",
|
||||
data: input.file.data,
|
||||
},
|
||||
],
|
||||
content: `${pdfParserUserPrompt}\n\n--- EXTRACTED RESUME TEXT ---\n${pdfText}\n--- END OF EXTRACTED TEXT ---`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
|
||||
|
||||
return resumeDataSchema.parse({
|
||||
...result.output,
|
||||
customSections: [],
|
||||
picture: defaultResumeData.picture,
|
||||
metadata: defaultResumeData.metadata,
|
||||
});
|
||||
return parseAndValidateResumeJson(result.text);
|
||||
}
|
||||
|
||||
type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
@@ -128,36 +319,28 @@ type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
|
||||
const model = getModel(input);
|
||||
|
||||
const docxText = await extractDocxText(input.file.data).catch((error: unknown) =>
|
||||
logAndRethrow("Failed to parse DOCX locally", error),
|
||||
);
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
output: Output.object({ schema: resumeDataSchema }),
|
||||
messages: [
|
||||
{ role: "system", content: docxParserSystemPrompt },
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
docxParserSystemPrompt +
|
||||
"\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n" +
|
||||
JSON.stringify(aiExtractionTemplate, null, 2),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: docxParserUserPrompt },
|
||||
{
|
||||
type: "file",
|
||||
filename: input.file.name,
|
||||
mediaType: input.mediaType,
|
||||
data: input.file.data,
|
||||
},
|
||||
],
|
||||
content: `${docxParserUserPrompt}\n\n--- EXTRACTED RESUME TEXT ---\n${docxText}\n--- END OF EXTRACTED TEXT ---`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
|
||||
|
||||
return resumeDataSchema.parse({
|
||||
...result.output,
|
||||
customSections: [],
|
||||
picture: defaultResumeData.picture,
|
||||
metadata: defaultResumeData.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatZodError(error: ZodError): string {
|
||||
return JSON.stringify(flattenError(error));
|
||||
return parseAndValidateResumeJson(result.text);
|
||||
}
|
||||
|
||||
function buildChatSystemPrompt(resumeData: ResumeData): string {
|
||||
|
||||
@@ -47,7 +47,10 @@ export function BuilderSidebarRight() {
|
||||
<>
|
||||
<SidebarEdge scrollAreaRef={scrollAreaRef} />
|
||||
|
||||
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background overflow-hidden sm:me-12">
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
className="@container h-[calc(100svh-3.5rem)] overflow-hidden bg-background sm:me-12"
|
||||
>
|
||||
<div className="space-y-4 p-4">
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
@@ -85,18 +88,18 @@ function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
|
||||
<BuilderSidebarEdge side="right">
|
||||
<div className="no-scrollbar min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex min-h-full flex-col items-center justify-center gap-y-2">
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Button
|
||||
key={section}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Button
|
||||
key={section}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BuilderSidebarEdge>
|
||||
);
|
||||
|
||||
@@ -109,3 +109,13 @@ export function sanitizeCss(css: string): string {
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given value is a plain JSON object (not null, not array, not other types).
|
||||
*
|
||||
* @param value - The value to check.
|
||||
* @returns True if the value is a plain object, false otherwise.
|
||||
*/
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user