diff --git a/docs-site/docs/features/settings.md b/docs-site/docs/features/settings.md index 4026a6d..520da9e 100644 --- a/docs-site/docs/features/settings.md +++ b/docs-site/docs/features/settings.md @@ -71,6 +71,8 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta ![Display settings section](/img/features/settings-display-section.png) - Toggle visa sponsor badge visibility in job lists/details +- Toggle `Render Markdown in job descriptions` to control whether expanded job descriptions show formatted headings, lists, bold text, and code blocks +- Default: Markdown rendering is enabled ### Writing Style & Language diff --git a/orchestrator/src/client/components/JobDescriptionMarkdown.tsx b/orchestrator/src/client/components/JobDescriptionMarkdown.tsx new file mode 100644 index 0000000..e64d6ce --- /dev/null +++ b/orchestrator/src/client/components/JobDescriptionMarkdown.tsx @@ -0,0 +1,68 @@ +import type React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface JobDescriptionMarkdownProps { + className?: string; + description: string; +} + +const SAFE_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + +const getSafeHref = (href?: string) => { + if (!href) return undefined; + + try { + const url = new URL(href, "https://job-ops.local"); + if ( + url.origin === "https://job-ops.local" && + !href.startsWith("http://") && + !href.startsWith("https://") && + !href.startsWith("mailto:") + ) { + return undefined; + } + + return SAFE_PROTOCOLS.has(url.protocol) ? href : undefined; + } catch { + return undefined; + } +}; + +export const JobDescriptionMarkdown: React.FC = ({ + className, + description, +}) => { + return ( +
+ null, + a: ({ children, href, ...props }) => { + const safeHref = getSafeHref(href); + if (!safeHref) return {children}; + + return ( + + {children} + + ); + }, + }} + > + {description} + +
+ ); +}; diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index aeb52ad..47b226e 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -1,3 +1,4 @@ +import { useSettings } from "@client/hooks/useSettings"; import type { Job } from "@shared/types.js"; import { ChevronUp, @@ -9,6 +10,7 @@ import { } from "lucide-react"; import type React from "react"; import { useMemo, useState } from "react"; +import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -21,7 +23,7 @@ import { FitAssessment, JobHeader, TailoredSummary } from ".."; import { KbdHint } from "../KbdHint"; import { OpenJobListingButton } from "../OpenJobListingButton"; import { CollapsibleSection } from "./CollapsibleSection"; -import { getPlainDescription } from "./helpers"; +import { getRenderableJobDescription } from "./helpers"; interface DecideModeProps { job: Job; @@ -46,9 +48,10 @@ export const DecideMode: React.FC = ({ }) => { const [showDescription, setShowDescription] = useState(false); const jobLink = job.applicationLink || job.jobUrl; + const { renderMarkdownInJobDescriptions } = useSettings(); const description = useMemo( - () => getPlainDescription(job.jobDescription), + () => getRenderableJobDescription(job.jobDescription), [job.jobDescription], ); @@ -103,9 +106,13 @@ export const DecideMode: React.FC = ({ label={`${showDescription ? "Hide" : "View"} Full Job Description`} >
-

- {description} -

+ {renderMarkdownInJobDescriptions ? ( + + ) : ( +

+ {description} +

+ )}
diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx index f2e61ba..16f9816 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx @@ -12,6 +12,11 @@ import { DiscoveredPanel } from "./DiscoveredPanel"; const render = (ui: Parameters[0]) => renderWithQueryClient(ui); +const mockSettings = { + showSponsorInfo: false, + renderMarkdownInJobDescriptions: true, +}; + vi.mock("@/components/ui/dropdown-menu", () => { return { DropdownMenu: ({ children }: { children: React.ReactNode }) => ( @@ -45,7 +50,7 @@ vi.mock("@/components/ui/dropdown-menu", () => { }); vi.mock("@client/hooks/useSettings", () => ({ - useSettings: () => ({ showSponsorInfo: false }), + useSettings: () => mockSettings, })); vi.mock("@client/api", () => ({ @@ -91,6 +96,8 @@ vi.mock("sonner", () => ({ describe("DiscoveredPanel", () => { beforeEach(() => { vi.clearAllMocks(); + mockSettings.showSponsorInfo = false; + mockSettings.renderMarkdownInJobDescriptions = true; }); it("re-runs the fit assessment from the menu", async () => { @@ -162,4 +169,64 @@ describe("DiscoveredPanel", () => { screen.getByRole("link", { name: /open job listing/i }), ).toHaveAttribute("href", "https://example.com/jobs/visit-me"); }); + + it("renders markdown formatting in the expanded job description when markdown rendering is enabled", () => { + const job = createJob({ + jobDescription: + "# Responsibilities\n\n- Build APIs\n- Improve reliability", + }); + + render( + + + , + ); + + fireEvent.click( + screen.getByRole("button", { name: /view full job description/i }), + ); + + expect( + screen.getByRole("heading", { name: "Responsibilities" }), + ).toBeInTheDocument(); + expect(screen.getByText("Build APIs")).toBeInTheDocument(); + expect(screen.queryByText("# Responsibilities")).not.toBeInTheDocument(); + }); + + it("renders raw markdown in the expanded job description when markdown rendering is disabled", () => { + mockSettings.renderMarkdownInJobDescriptions = false; + + const job = createJob({ + jobDescription: + "# Responsibilities\n\n- Build APIs\n- Improve reliability", + }); + + const rendered = render( + + + , + ); + + fireEvent.click( + screen.getByRole("button", { name: /view full job description/i }), + ); + + const rawDescription = rendered.container.querySelector( + "p.whitespace-pre-wrap", + ); + expect(rawDescription?.textContent).toBe( + "# Responsibilities\n\n- Build APIs\n- Improve reliability", + ); + expect( + screen.queryByRole("heading", { name: "Responsibilities" }), + ).not.toBeInTheDocument(); + }); }); diff --git a/orchestrator/src/client/components/discovered-panel/helpers.ts b/orchestrator/src/client/components/discovered-panel/helpers.ts index 8d6107d..66a5897 100644 --- a/orchestrator/src/client/components/discovered-panel/helpers.ts +++ b/orchestrator/src/client/components/discovered-panel/helpers.ts @@ -1,9 +1 @@ -import { stripHtml } from "@/lib/utils"; - -export const getPlainDescription = (jobDescription?: string | null) => { - if (!jobDescription) return "No description available."; - if (jobDescription.includes("<") && jobDescription.includes(">")) { - return stripHtml(jobDescription); - } - return jobDescription; -}; +export { getRenderableJobDescription } from "@client/lib/jobDescription"; diff --git a/orchestrator/src/client/hooks/useSettings.test.ts b/orchestrator/src/client/hooks/useSettings.test.ts index b853160..966953d 100644 --- a/orchestrator/src/client/hooks/useSettings.test.ts +++ b/orchestrator/src/client/hooks/useSettings.test.ts @@ -1,3 +1,4 @@ +import { createAppSettings } from "@shared/testing/factories.js"; import { act, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; @@ -15,8 +16,15 @@ describe("useSettings", () => { }); it("fetches settings on mount if not already cached", async () => { - const mockSettings = { showSponsorInfo: false }; - vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any); + const mockSettings = createAppSettings({ + showSponsorInfo: { value: false, default: true, override: false }, + renderMarkdownInJobDescriptions: { + value: false, + default: true, + override: false, + }, + }); + vi.mocked(api.getSettings).mockResolvedValue(mockSettings); const { result } = renderHookWithQueryClient(() => useSettings()); @@ -28,6 +36,7 @@ describe("useSettings", () => { }); expect(result.current.showSponsorInfo).toBe(false); + expect(result.current.renderMarkdownInJobDescriptions).toBe(false); expect(api.getSettings).toHaveBeenCalledTimes(1); }); @@ -39,15 +48,23 @@ describe("useSettings", () => { await waitFor(() => { // settings is null, so showSponsorInfo should default to true expect(result.current.showSponsorInfo).toBe(true); + expect(result.current.renderMarkdownInJobDescriptions).toBe(true); }); }); it("provides a refresh function that updates settings", async () => { - const initialSettings = { showSponsorInfo: true }; - const updatedSettings = { showSponsorInfo: false }; + const initialSettings = createAppSettings(); + const updatedSettings = createAppSettings({ + showSponsorInfo: { value: false, default: true, override: false }, + renderMarkdownInJobDescriptions: { + value: false, + default: true, + override: false, + }, + }); - vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings as any); - vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any); + vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings); + vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings); const { result } = renderHookWithQueryClient(() => useSettings()); @@ -66,6 +83,7 @@ describe("useSettings", () => { expect(refreshed).toEqual(updatedSettings); expect(result.current.showSponsorInfo).toBe(false); + expect(result.current.renderMarkdownInJobDescriptions).toBe(false); }); it("handles errors when fetching settings", async () => { diff --git a/orchestrator/src/client/hooks/useSettings.ts b/orchestrator/src/client/hooks/useSettings.ts index 5af7794..5940698 100644 --- a/orchestrator/src/client/hooks/useSettings.ts +++ b/orchestrator/src/client/hooks/useSettings.ts @@ -26,7 +26,9 @@ export function useSettings() { settings, error: error ?? null, isLoading: isLoading || (!!isFetching && !settings && !error), - showSponsorInfo: settings?.showSponsorInfo ?? true, + showSponsorInfo: settings?.showSponsorInfo?.value ?? true, + renderMarkdownInJobDescriptions: + settings?.renderMarkdownInJobDescriptions?.value ?? true, refreshSettings, }; } diff --git a/orchestrator/src/client/lib/jobDescription.ts b/orchestrator/src/client/lib/jobDescription.ts new file mode 100644 index 0000000..ca6fa52 --- /dev/null +++ b/orchestrator/src/client/lib/jobDescription.ts @@ -0,0 +1,20 @@ +import { stripHtml } from "@/lib/utils"; + +export const getRenderableJobDescription = (jobDescription?: string | null) => { + if (!jobDescription) return "No description available."; + + const plainText = + jobDescription.includes("<") && jobDescription.includes(">") + ? stripHtml(jobDescription) + : jobDescription; + + const normalizedLineBreaks = plainText.replace(/\r\n/g, "\n"); + if ( + normalizedLineBreaks.includes("\\n") && + !normalizedLineBreaks.includes("\n") + ) { + return normalizedLineBreaks.replace(/\\n/g, "\n"); + } + + return normalizedLineBreaks; +}; diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 171344f..f0d9023 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -73,6 +73,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { rxresumeMode: "v5", rxresumeBaseResumeId: null, showSponsorInfo: null, + renderMarkdownInJobDescriptions: null, chatStyleTone: "", chatStyleFormality: "", chatStyleConstraints: "", @@ -149,6 +150,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { rxresumeMode: null, rxresumeBaseResumeId: null, showSponsorInfo: null, + renderMarkdownInJobDescriptions: null, chatStyleTone: null, chatStyleFormality: null, chatStyleConstraints: null, @@ -192,6 +194,8 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ rxresumeMode: data.rxresumeMode.override ?? data.rxresumeMode.value, rxresumeBaseResumeId: data.rxresumeBaseResumeId, showSponsorInfo: data.showSponsorInfo.override, + renderMarkdownInJobDescriptions: + data.renderMarkdownInJobDescriptions.override, chatStyleTone: data.chatStyleTone.override ?? "", chatStyleFormality: data.chatStyleFormality.override ?? "", chatStyleConstraints: data.chatStyleConstraints.override ?? "", @@ -296,8 +300,14 @@ const getDerivedSettings = (settings: AppSettings | null) => { default: settings?.jobCompleteWebhookUrl?.default ?? "", }, display: { - effective: settings?.showSponsorInfo?.value ?? true, - default: settings?.showSponsorInfo?.default ?? true, + showSponsorInfo: { + effective: settings?.showSponsorInfo?.value ?? true, + default: settings?.showSponsorInfo?.default ?? true, + }, + renderMarkdownInJobDescriptions: { + effective: settings?.renderMarkdownInJobDescriptions?.value ?? true, + default: settings?.renderMarkdownInJobDescriptions?.default ?? true, + }, }, chat: { tone: { @@ -853,7 +863,14 @@ export const SettingsPage: React.FC = () => { ...(dirtyFields.rxresumeBaseResumeId ? { rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId) } : {}), - showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default), + showSponsorInfo: nullIfSame( + data.showSponsorInfo, + display.showSponsorInfo.default, + ), + renderMarkdownInJobDescriptions: nullIfSame( + data.renderMarkdownInJobDescriptions, + display.renderMarkdownInJobDescriptions.default, + ), chatStyleTone: normalizeString(data.chatStyleTone), chatStyleFormality: normalizeString(data.chatStyleFormality), chatStyleConstraints: normalizeString(data.chatStyleConstraints), diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx index 6f82cdd..cf9837a 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx @@ -10,6 +10,15 @@ import { JobDetailPanel } from "./JobDetailPanel"; const render = (ui: Parameters[0]) => renderWithQueryClient(ui); +const mockSettings = { + settings: null, + error: null, + isLoading: false, + showSponsorInfo: true, + renderMarkdownInJobDescriptions: true, + refreshSettings: vi.fn(), +}; + vi.mock("@/components/ui/dropdown-menu", () => { return { DropdownMenu: ({ children }: { children: React.ReactNode }) => ( @@ -51,6 +60,10 @@ vi.mock("@client/components", () => ({ TailoredSummary: () =>
, })); +vi.mock("@client/hooks/useSettings", () => ({ + useSettings: () => mockSettings, +})); + vi.mock("@client/components/ReadyPanel", () => ({ ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
@@ -146,6 +159,7 @@ const renderJobDetailPanel = async ( describe("JobDetailPanel", () => { beforeEach(() => { vi.clearAllMocks(); + mockSettings.renderMarkdownInJobDescriptions = true; }); it("renders the discovered panel when active tab is discovered", async () => { @@ -188,6 +202,51 @@ describe("JobDetailPanel", () => { expect(screen.getByText("Hello world")).toBeInTheDocument(); }); + it("renders markdown in the description tab when enabled", async () => { + await renderJobDetailPanel({ + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ + jobDescription: "# Responsibilities\n\n- Build APIs", + }), + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + }); + + fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i })); + + expect( + screen.getByRole("heading", { name: "Responsibilities" }), + ).toBeInTheDocument(); + expect(screen.queryByText("# Responsibilities")).not.toBeInTheDocument(); + }); + + it("renders raw markdown in the description tab when disabled", async () => { + mockSettings.renderMarkdownInJobDescriptions = false; + + const rendered = await renderJobDetailPanel({ + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ + jobDescription: "# Responsibilities\n\n- Build APIs", + }), + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + }); + + fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i })); + + const rawDescription = rendered.container.querySelector( + "div.whitespace-pre-wrap", + ); + expect(rawDescription?.textContent).toBe( + "# Responsibilities\n\n- Build APIs", + ); + expect( + screen.queryByRole("heading", { name: "Responsibilities" }), + ).not.toBeInTheDocument(); + }); + it("saves an edited description", async () => { const onJobUpdated = vi.fn().mockResolvedValue(undefined); vi.mocked(api.updateJob).mockResolvedValue(undefined as any); diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index 5483e14..901afcd 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -13,6 +13,7 @@ import { useSkipJobMutation, } from "@client/hooks/queries/useJobMutations"; import { useProfile } from "@client/hooks/useProfile"; +import { useSettings } from "@client/hooks/useSettings"; import type { Job, JobListItem } from "@shared/types.js"; import { CheckCircle2, @@ -28,9 +29,9 @@ import { } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { toast } from "sonner"; +import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown"; +import { getRenderableJobDescription } from "@/client/lib/jobDescription"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -46,7 +47,6 @@ import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, - stripHtml, } from "@/lib/utils"; import type { FilterTab } from "./constants"; @@ -82,6 +82,7 @@ export const JobDetailPanel: React.FC = ({ const skipJobMutation = useSkipJobMutation(); const { personName } = useProfile(); + const { renderMarkdownInJobDescriptions } = useSettings(); const handleTailoringDirtyChange = useCallback( (isDirty: boolean) => { @@ -105,10 +106,7 @@ export const JobDetailPanel: React.FC = ({ }, [onPauseRefreshChange]); const description = useMemo(() => { - if (!selectedJob?.jobDescription) return "No description available."; - const jd = selectedJob.jobDescription; - if (jd.includes("<") && jd.includes(">")) return stripHtml(jd); - return jd; + return getRenderableJobDescription(selectedJob?.jobDescription); }, [selectedJob]); useEffect(() => { @@ -783,11 +781,11 @@ export const JobDetailPanel: React.FC = ({
+ ) : renderMarkdownInJobDescriptions ? ( + ) : (
- - {description} - + {description}
)} diff --git a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx index 1717ee4..7dd55a4 100644 --- a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx @@ -21,10 +21,7 @@ export const DisplaySettingsSection: React.FC = ({ isLoading, isSaving, }) => { - const { - default: defaultShowSponsorInfo, - effective: effectiveShowSponsorInfo, - } = values; + const { showSponsorInfo, renderMarkdownInJobDescriptions } = values; const { control } = useFormContext(); return ( @@ -41,7 +38,7 @@ export const DisplaySettingsSection: React.FC = ({ render={({ field }) => ( { field.onChange( checked === "indeterminate" ? null : checked === true, @@ -68,17 +65,77 @@ export const DisplaySettingsSection: React.FC = ({ -
+
+ ( + { + field.onChange( + checked === "indeterminate" ? null : checked === true, + ); + }} + disabled={isLoading || isSaving} + /> + )} + /> +
+ +

+ Show headings, bold text, lists, and code blocks as formatted + content when you expand a full job description. Turn this off if + you prefer the raw source text. +

+
+
+ + + +
-
Effective
+
+ Sponsor info effective +
- {effectiveShowSponsorInfo ? "Enabled" : "Disabled"} + {showSponsorInfo.effective ? "Enabled" : "Disabled"}
-
Default
+
+ Sponsor info default +
- {defaultShowSponsorInfo ? "Enabled" : "Disabled"} + {showSponsorInfo.default ? "Enabled" : "Disabled"} +
+
+
+
+ Markdown rendering effective +
+
+ {renderMarkdownInJobDescriptions.effective + ? "Enabled" + : "Disabled"} +
+
+
+
+ Markdown rendering default +
+
+ {renderMarkdownInJobDescriptions.default + ? "Enabled" + : "Disabled"}
diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index 7770187..fa4bb17 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -18,7 +18,10 @@ export type ModelValues = EffectiveDefault & { }; export type WebhookValues = EffectiveDefault; -export type DisplayValues = EffectiveDefault; +export type DisplayValues = { + showSponsorInfo: EffectiveDefault; + renderMarkdownInJobDescriptions: EffectiveDefault; +}; export type ChatValues = { tone: EffectiveDefault; formality: EffectiveDefault; diff --git a/orchestrator/src/server/config/demo-defaults.data.ts b/orchestrator/src/server/config/demo-defaults.data.ts index 1752337..8565d2b 100644 --- a/orchestrator/src/server/config/demo-defaults.data.ts +++ b/orchestrator/src/server/config/demo-defaults.data.ts @@ -21,6 +21,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = { "full stack engineer", ]), showSponsorInfo: "1", + renderMarkdownInJobDescriptions: "1", backupEnabled: "0", backupHour: "2", backupMaxCount: "5", diff --git a/shared/src/settings-registry.test.ts b/shared/src/settings-registry.test.ts index 3759361..68d87ca 100644 --- a/shared/src/settings-registry.test.ts +++ b/shared/src/settings-registry.test.ts @@ -57,6 +57,12 @@ describe("settingsRegistry helpers", () => { expect(settingsRegistry.showSponsorInfo.parse("false")).toBe(false); expect(settingsRegistry.showSponsorInfo.parse("")).toBeNull(); expect(settingsRegistry.showSponsorInfo.parse(undefined)).toBeNull(); + expect(settingsRegistry.renderMarkdownInJobDescriptions.parse("1")).toBe( + true, + ); + expect(settingsRegistry.renderMarkdownInJobDescriptions.parse("0")).toBe( + false, + ); }); it("serializes bit bools correctly", () => { @@ -64,6 +70,12 @@ describe("settingsRegistry helpers", () => { expect(settingsRegistry.showSponsorInfo.serialize(false)).toBe("0"); expect(settingsRegistry.showSponsorInfo.serialize(null)).toBeNull(); expect(settingsRegistry.showSponsorInfo.serialize(undefined)).toBeNull(); + expect( + settingsRegistry.renderMarkdownInJobDescriptions.serialize(true), + ).toBe("1"); + expect( + settingsRegistry.renderMarkdownInJobDescriptions.serialize(false), + ).toBe("0"); }); }); diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts index 2b0fc04..f2070b7 100644 --- a/shared/src/settings-registry.ts +++ b/shared/src/settings-registry.ts @@ -371,6 +371,13 @@ export const settingsRegistry = { parse: parseBitBoolOrNull, serialize: serializeBitBool, }, + renderMarkdownInJobDescriptions: { + kind: "typed" as const, + schema: z.boolean(), + default: (): boolean => true, + parse: parseBitBoolOrNull, + serialize: serializeBitBool, + }, chatStyleTone: { kind: "typed" as const, schema: z.string().trim().max(100), diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts index 380f06f..e6af7a4 100644 --- a/shared/src/testing/factories.ts +++ b/shared/src/testing/factories.ts @@ -186,6 +186,11 @@ export const createAppSettings = ( override: null, }, showSponsorInfo: { value: true, default: true, override: null }, + renderMarkdownInJobDescriptions: { + value: true, + default: true, + override: null, + }, chatStyleTone: { value: "professional", default: "professional", diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts index af89c81..bb1fd62 100644 --- a/shared/src/types/settings.ts +++ b/shared/src/types/settings.ts @@ -162,6 +162,7 @@ export interface AppSettings { jobspyResultsWanted: Resolved; jobspyCountryIndeed: Resolved; showSponsorInfo: Resolved; + renderMarkdownInJobDescriptions: Resolved; chatStyleTone: Resolved; chatStyleFormality: Resolved; chatStyleConstraints: Resolved;