diff --git a/apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx b/apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx index ecb4d822a4..c2615960b3 100644 --- a/apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx +++ b/apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx @@ -33,7 +33,8 @@ import { Input, Tooltip, } from "@reactive-resume/ui"; -import { cn, generateRandomName, kebabCase } from "@reactive-resume/utils"; +import { cn, generateRandomName } from "@reactive-resume/utils"; +import slugify from "@sindresorhus/slugify"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -71,7 +72,7 @@ export const ResumeDialog = () => { }, [isOpen, payload]); useEffect(() => { - const slug = kebabCase(form.watch("title")); + const slug = slugify(form.watch("title")); form.setValue("slug", slug); }, [form.watch("title")]); @@ -122,7 +123,7 @@ export const ResumeDialog = () => { const onGenerateRandomName = () => { const name = generateRandomName(); form.setValue("title", name); - form.setValue("slug", kebabCase(name)); + form.setValue("slug", slugify(name)); }; const onCreateSample = async () => { @@ -130,7 +131,7 @@ export const ResumeDialog = () => { await duplicateResume({ title: randomName, - slug: kebabCase(randomName), + slug: slugify(randomName), data: sampleResume, }); diff --git a/apps/server/src/printer/printer.service.ts b/apps/server/src/printer/printer.service.ts index fa06b61325..262bac5c27 100644 --- a/apps/server/src/printer/printer.service.ts +++ b/apps/server/src/printer/printer.service.ts @@ -209,6 +209,8 @@ export class PrinterService { return resumeUrl; } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException( ErrorMessage.ResumePrinterError, (error as Error).message, diff --git a/apps/server/src/resume/resume.service.ts b/apps/server/src/resume/resume.service.ts index eab3061fb3..53d4ab2432 100644 --- a/apps/server/src/resume/resume.service.ts +++ b/apps/server/src/resume/resume.service.ts @@ -8,7 +8,8 @@ import { Prisma } from "@prisma/client"; import { CreateResumeDto, ImportResumeDto, ResumeDto, UpdateResumeDto } from "@reactive-resume/dto"; import { defaultResumeData, ResumeData } from "@reactive-resume/schema"; import type { DeepPartial } from "@reactive-resume/utils"; -import { ErrorMessage, generateRandomName, kebabCase } from "@reactive-resume/utils"; +import { ErrorMessage, generateRandomName } from "@reactive-resume/utils"; +import slugify from "@sindresorhus/slugify"; import deepmerge from "deepmerge"; import { PrismaService } from "nestjs-prisma"; @@ -40,7 +41,7 @@ export class ResumeService { userId, title: createResumeDto.title, visibility: createResumeDto.visibility, - slug: createResumeDto.slug ?? kebabCase(createResumeDto.title), + slug: createResumeDto.slug ?? slugify(createResumeDto.title), }, }); } @@ -54,7 +55,7 @@ export class ResumeService { visibility: "private", data: importResumeDto.data, title: importResumeDto.title ?? randomTitle, - slug: importResumeDto.slug ?? kebabCase(randomTitle), + slug: importResumeDto.slug ?? slugify(randomTitle), }, }); } diff --git a/apps/server/src/storage/storage.service.ts b/apps/server/src/storage/storage.service.ts index bf25998ed0..e2104e4f5e 100644 --- a/apps/server/src/storage/storage.service.ts +++ b/apps/server/src/storage/storage.service.ts @@ -1,6 +1,7 @@ import { Injectable, InternalServerErrorException, Logger, OnModuleInit } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { createId } from "@paralleldrive/cuid2"; +import slugify from "@sindresorhus/slugify"; import { MinioClient, MinioService } from "nestjs-minio-client"; import sharp from "sharp"; @@ -116,14 +117,19 @@ export class StorageService implements OnModuleInit { ) { const extension = type === "resumes" ? "pdf" : "jpg"; const storageUrl = this.configService.getOrThrow("STORAGE_URL"); - const filepath = `${userId}/${type}/${filename}.${extension}`; + + let normalizedFilename = slugify(filename); + if (!normalizedFilename) normalizedFilename = createId(); + + const filepath = `${userId}/${type}/${normalizedFilename}.${extension}`; const url = `${storageUrl}/${filepath}`; + const metadata = extension === "jpg" ? { "Content-Type": "image/jpeg" } : { "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename=${filename}.${extension}`, + "Content-Disposition": `attachment; filename=${normalizedFilename}.${extension}`, }; try { diff --git a/libs/dto/package.json b/libs/dto/package.json index e310d272b0..a88a381489 100644 --- a/libs/dto/package.json +++ b/libs/dto/package.json @@ -11,6 +11,7 @@ "dependencies": { "@reactive-resume/utils": "*", "@reactive-resume/schema": "*", + "@sindresorhus/slugify": "^2.2.1", "nestjs-zod": "^3.0.0", "@swc/helpers": "~0.5.11", "zod": "^3.24.1" diff --git a/libs/dto/src/resume/create.ts b/libs/dto/src/resume/create.ts index 4ee06fd817..2bebf05adb 100644 --- a/libs/dto/src/resume/create.ts +++ b/libs/dto/src/resume/create.ts @@ -1,10 +1,14 @@ -import { kebabCase } from "@reactive-resume/utils"; +import slugify from "@sindresorhus/slugify"; import { createZodDto } from "nestjs-zod/dto"; import { z } from "zod"; export const createResumeSchema = z.object({ title: z.string().min(1), - slug: z.string().min(1).transform(kebabCase).optional(), + slug: z + .string() + .min(1) + .transform((value) => slugify(value)) + .optional(), visibility: z.enum(["public", "private"]).default("private"), }); diff --git a/libs/dto/src/resume/import.ts b/libs/dto/src/resume/import.ts index 0510d795de..702c3028e2 100644 --- a/libs/dto/src/resume/import.ts +++ b/libs/dto/src/resume/import.ts @@ -1,11 +1,15 @@ import { resumeDataSchema } from "@reactive-resume/schema"; -import { kebabCase } from "@reactive-resume/utils"; +import slugify from "@sindresorhus/slugify"; import { createZodDto } from "nestjs-zod/dto"; import { z } from "zod"; export const importResumeSchema = z.object({ title: z.string().optional(), - slug: z.string().min(1).transform(kebabCase).optional(), + slug: z + .string() + .min(1) + .transform((value) => slugify(value)) + .optional(), visibility: z.enum(["public", "private"]).default("private").optional(), data: resumeDataSchema, }); diff --git a/libs/utils/src/namespaces/string.ts b/libs/utils/src/namespaces/string.ts index b7db484153..d88d7087c3 100644 --- a/libs/utils/src/namespaces/string.ts +++ b/libs/utils/src/namespaces/string.ts @@ -30,17 +30,6 @@ export const extractUrl = (string: string) => { return result ? result[0] : null; }; -export const kebabCase = (string?: string | null) => { - if (!string) return ""; - - return ( - string - .match(/[A-Z]{2,}(?=[A-Z][a-z]+\d*|\b)|[A-Z]?[a-z]+\d*|[A-Z]|\d+/gu) - ?.join("-") - .toLowerCase() ?? "" - ); -}; - export const generateRandomName = () => { return uniqueNamesGenerator({ dictionaries: [adjectives, adjectives, animals], diff --git a/libs/utils/src/namespaces/tests/string.test.ts b/libs/utils/src/namespaces/tests/string.test.ts index e2627635fc..d048a9cc4d 100644 --- a/libs/utils/src/namespaces/tests/string.test.ts +++ b/libs/utils/src/namespaces/tests/string.test.ts @@ -6,7 +6,6 @@ import { getInitials, isEmptyString, isUrl, - kebabCase, processUsername, } from "../string"; @@ -40,16 +39,6 @@ describe("extractUrl", () => { }); }); -describe("kebabCase", () => { - it("converts a string to kebab-case", () => { - expect(kebabCase("fooBar")).toBe("foo-bar"); - expect(kebabCase("Foo Bar")).toBe("foo-bar"); - expect(kebabCase("foo_bar")).toBe("foo-bar"); - expect(kebabCase("")).toBe(""); - expect(kebabCase(null)).toBe(""); - }); -}); - describe("generateRandomName", () => { it("generates a random name", () => { const name = generateRandomName(); diff --git a/package.json b/package.json index edad064aea..2e0ae59385 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@reactive-resume/source", "description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.", - "version": "4.3.3", + "version": "4.3.4", "license": "MIT", "private": true, "author": { @@ -168,6 +168,7 @@ "@radix-ui/react-toggle-group": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-visually-hidden": "^1.1.1", + "@sindresorhus/slugify": "^2.2.1", "@swc/helpers": "^0.5.15", "@tanstack/react-query": "^5.64.0", "@tiptap/extension-highlight": "^2.11.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8629431997..010eb40408 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: '@radix-ui/react-visually-hidden': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@sindresorhus/slugify': + specifier: ^2.2.1 + version: 2.2.1 '@swc/helpers': specifier: ^0.5.15 version: 0.5.15 @@ -3749,6 +3752,14 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/slugify@2.2.1': + resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==} + engines: {node: '>=12'} + + '@sindresorhus/transliterate@1.6.0': + resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==} + engines: {node: '>=12'} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -15388,6 +15399,15 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@sindresorhus/slugify@2.2.1': + dependencies: + '@sindresorhus/transliterate': 1.6.0 + escape-string-regexp: 5.0.0 + + '@sindresorhus/transliterate@1.6.0': + dependencies: + escape-string-regexp: 5.0.0 + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8