fix: render markdown in expanded job descriptions (#297)

* fix: render markdown in expanded job descriptions

* fix: respect markdown job description setting and harden rendering
This commit is contained in:
Ammad Ali 2026-03-22 17:01:34 +00:00 committed by GitHub
parent 0b22c08d7d
commit 69a10acd4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 382 additions and 46 deletions

View File

@ -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) ![Display settings section](/img/features/settings-display-section.png)
- Toggle visa sponsor badge visibility in job lists/details - 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 ### Writing Style & Language

View File

@ -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<JobDescriptionMarkdownProps> = ({
className,
description,
}) => {
return (
<div
className={
className ??
"text-sm leading-relaxed text-foreground [&_h1]:text-lg [&_h1]:font-semibold [&_h2]:text-base [&_h2]:font-semibold [&_h3]:font-semibold [&_p]:my-3 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-background [&_pre]:p-3 [&_code]:rounded [&_code]:bg-background/80 [&_code]:px-1 [&_code]:py-0.5 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_a]:text-primary [&_a]:underline"
}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: () => null,
a: ({ children, href, ...props }) => {
const safeHref = getSafeHref(href);
if (!safeHref) return <span>{children}</span>;
return (
<a
{...props}
href={safeHref}
target="_blank"
rel="noopener noreferrer nofollow"
>
{children}
</a>
);
},
}}
>
{description}
</ReactMarkdown>
</div>
);
};

View File

@ -1,3 +1,4 @@
import { useSettings } from "@client/hooks/useSettings";
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import { import {
ChevronUp, ChevronUp,
@ -9,6 +10,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -21,7 +23,7 @@ import { FitAssessment, JobHeader, TailoredSummary } from "..";
import { KbdHint } from "../KbdHint"; import { KbdHint } from "../KbdHint";
import { OpenJobListingButton } from "../OpenJobListingButton"; import { OpenJobListingButton } from "../OpenJobListingButton";
import { CollapsibleSection } from "./CollapsibleSection"; import { CollapsibleSection } from "./CollapsibleSection";
import { getPlainDescription } from "./helpers"; import { getRenderableJobDescription } from "./helpers";
interface DecideModeProps { interface DecideModeProps {
job: Job; job: Job;
@ -46,9 +48,10 @@ export const DecideMode: React.FC<DecideModeProps> = ({
}) => { }) => {
const [showDescription, setShowDescription] = useState(false); const [showDescription, setShowDescription] = useState(false);
const jobLink = job.applicationLink || job.jobUrl; const jobLink = job.applicationLink || job.jobUrl;
const { renderMarkdownInJobDescriptions } = useSettings();
const description = useMemo( const description = useMemo(
() => getPlainDescription(job.jobDescription), () => getRenderableJobDescription(job.jobDescription),
[job.jobDescription], [job.jobDescription],
); );
@ -103,9 +106,13 @@ export const DecideMode: React.FC<DecideModeProps> = ({
label={`${showDescription ? "Hide" : "View"} Full Job Description`} label={`${showDescription ? "Hide" : "View"} Full Job Description`}
> >
<div className="rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner"> <div className="rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner">
{renderMarkdownInJobDescriptions ? (
<JobDescriptionMarkdown description={description} />
) : (
<p className="text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed"> <p className="text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed">
{description} {description}
</p> </p>
)}
</div> </div>
</CollapsibleSection> </CollapsibleSection>
</div> </div>

View File

@ -12,6 +12,11 @@ import { DiscoveredPanel } from "./DiscoveredPanel";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) => const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui); renderWithQueryClient(ui);
const mockSettings = {
showSponsorInfo: false,
renderMarkdownInJobDescriptions: true,
};
vi.mock("@/components/ui/dropdown-menu", () => { vi.mock("@/components/ui/dropdown-menu", () => {
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => ( DropdownMenu: ({ children }: { children: React.ReactNode }) => (
@ -45,7 +50,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
}); });
vi.mock("@client/hooks/useSettings", () => ({ vi.mock("@client/hooks/useSettings", () => ({
useSettings: () => ({ showSponsorInfo: false }), useSettings: () => mockSettings,
})); }));
vi.mock("@client/api", () => ({ vi.mock("@client/api", () => ({
@ -91,6 +96,8 @@ vi.mock("sonner", () => ({
describe("DiscoveredPanel", () => { describe("DiscoveredPanel", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockSettings.showSponsorInfo = false;
mockSettings.renderMarkdownInJobDescriptions = true;
}); });
it("re-runs the fit assessment from the menu", async () => { it("re-runs the fit assessment from the menu", async () => {
@ -162,4 +169,64 @@ describe("DiscoveredPanel", () => {
screen.getByRole("link", { name: /open job listing/i }), screen.getByRole("link", { name: /open job listing/i }),
).toHaveAttribute("href", "https://example.com/jobs/visit-me"); ).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(
<MemoryRouter>
<DiscoveredPanel
job={job}
onJobUpdated={vi.fn()}
onJobMoved={vi.fn()}
/>
</MemoryRouter>,
);
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(
<MemoryRouter>
<DiscoveredPanel
job={job}
onJobUpdated={vi.fn()}
onJobMoved={vi.fn()}
/>
</MemoryRouter>,
);
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();
});
}); });

View File

@ -1,9 +1 @@
import { stripHtml } from "@/lib/utils"; export { getRenderableJobDescription } from "@client/lib/jobDescription";
export const getPlainDescription = (jobDescription?: string | null) => {
if (!jobDescription) return "No description available.";
if (jobDescription.includes("<") && jobDescription.includes(">")) {
return stripHtml(jobDescription);
}
return jobDescription;
};

View File

@ -1,3 +1,4 @@
import { createAppSettings } from "@shared/testing/factories.js";
import { act, waitFor } from "@testing-library/react"; import { act, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
@ -15,8 +16,15 @@ describe("useSettings", () => {
}); });
it("fetches settings on mount if not already cached", async () => { it("fetches settings on mount if not already cached", async () => {
const mockSettings = { showSponsorInfo: false }; const mockSettings = createAppSettings({
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any); showSponsorInfo: { value: false, default: true, override: false },
renderMarkdownInJobDescriptions: {
value: false,
default: true,
override: false,
},
});
vi.mocked(api.getSettings).mockResolvedValue(mockSettings);
const { result } = renderHookWithQueryClient(() => useSettings()); const { result } = renderHookWithQueryClient(() => useSettings());
@ -28,6 +36,7 @@ describe("useSettings", () => {
}); });
expect(result.current.showSponsorInfo).toBe(false); expect(result.current.showSponsorInfo).toBe(false);
expect(result.current.renderMarkdownInJobDescriptions).toBe(false);
expect(api.getSettings).toHaveBeenCalledTimes(1); expect(api.getSettings).toHaveBeenCalledTimes(1);
}); });
@ -39,15 +48,23 @@ describe("useSettings", () => {
await waitFor(() => { await waitFor(() => {
// settings is null, so showSponsorInfo should default to true // settings is null, so showSponsorInfo should default to true
expect(result.current.showSponsorInfo).toBe(true); expect(result.current.showSponsorInfo).toBe(true);
expect(result.current.renderMarkdownInJobDescriptions).toBe(true);
}); });
}); });
it("provides a refresh function that updates settings", async () => { it("provides a refresh function that updates settings", async () => {
const initialSettings = { showSponsorInfo: true }; const initialSettings = createAppSettings();
const updatedSettings = { showSponsorInfo: false }; 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(initialSettings);
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any); vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings);
const { result } = renderHookWithQueryClient(() => useSettings()); const { result } = renderHookWithQueryClient(() => useSettings());
@ -66,6 +83,7 @@ describe("useSettings", () => {
expect(refreshed).toEqual(updatedSettings); expect(refreshed).toEqual(updatedSettings);
expect(result.current.showSponsorInfo).toBe(false); expect(result.current.showSponsorInfo).toBe(false);
expect(result.current.renderMarkdownInJobDescriptions).toBe(false);
}); });
it("handles errors when fetching settings", async () => { it("handles errors when fetching settings", async () => {

View File

@ -26,7 +26,9 @@ export function useSettings() {
settings, settings,
error: error ?? null, error: error ?? null,
isLoading: isLoading || (!!isFetching && !settings && !error), isLoading: isLoading || (!!isFetching && !settings && !error),
showSponsorInfo: settings?.showSponsorInfo ?? true, showSponsorInfo: settings?.showSponsorInfo?.value ?? true,
renderMarkdownInJobDescriptions:
settings?.renderMarkdownInJobDescriptions?.value ?? true,
refreshSettings, refreshSettings,
}; };
} }

View File

@ -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;
};

View File

@ -73,6 +73,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
rxresumeMode: "v5", rxresumeMode: "v5",
rxresumeBaseResumeId: null, rxresumeBaseResumeId: null,
showSponsorInfo: null, showSponsorInfo: null,
renderMarkdownInJobDescriptions: null,
chatStyleTone: "", chatStyleTone: "",
chatStyleFormality: "", chatStyleFormality: "",
chatStyleConstraints: "", chatStyleConstraints: "",
@ -149,6 +150,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
rxresumeMode: null, rxresumeMode: null,
rxresumeBaseResumeId: null, rxresumeBaseResumeId: null,
showSponsorInfo: null, showSponsorInfo: null,
renderMarkdownInJobDescriptions: null,
chatStyleTone: null, chatStyleTone: null,
chatStyleFormality: null, chatStyleFormality: null,
chatStyleConstraints: null, chatStyleConstraints: null,
@ -192,6 +194,8 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
rxresumeMode: data.rxresumeMode.override ?? data.rxresumeMode.value, rxresumeMode: data.rxresumeMode.override ?? data.rxresumeMode.value,
rxresumeBaseResumeId: data.rxresumeBaseResumeId, rxresumeBaseResumeId: data.rxresumeBaseResumeId,
showSponsorInfo: data.showSponsorInfo.override, showSponsorInfo: data.showSponsorInfo.override,
renderMarkdownInJobDescriptions:
data.renderMarkdownInJobDescriptions.override,
chatStyleTone: data.chatStyleTone.override ?? "", chatStyleTone: data.chatStyleTone.override ?? "",
chatStyleFormality: data.chatStyleFormality.override ?? "", chatStyleFormality: data.chatStyleFormality.override ?? "",
chatStyleConstraints: data.chatStyleConstraints.override ?? "", chatStyleConstraints: data.chatStyleConstraints.override ?? "",
@ -296,9 +300,15 @@ const getDerivedSettings = (settings: AppSettings | null) => {
default: settings?.jobCompleteWebhookUrl?.default ?? "", default: settings?.jobCompleteWebhookUrl?.default ?? "",
}, },
display: { display: {
showSponsorInfo: {
effective: settings?.showSponsorInfo?.value ?? true, effective: settings?.showSponsorInfo?.value ?? true,
default: settings?.showSponsorInfo?.default ?? true, default: settings?.showSponsorInfo?.default ?? true,
}, },
renderMarkdownInJobDescriptions: {
effective: settings?.renderMarkdownInJobDescriptions?.value ?? true,
default: settings?.renderMarkdownInJobDescriptions?.default ?? true,
},
},
chat: { chat: {
tone: { tone: {
effective: settings?.chatStyleTone?.value ?? "professional", effective: settings?.chatStyleTone?.value ?? "professional",
@ -853,7 +863,14 @@ export const SettingsPage: React.FC = () => {
...(dirtyFields.rxresumeBaseResumeId ...(dirtyFields.rxresumeBaseResumeId
? { rxresumeBaseResumeId: normalizeString(data.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), chatStyleTone: normalizeString(data.chatStyleTone),
chatStyleFormality: normalizeString(data.chatStyleFormality), chatStyleFormality: normalizeString(data.chatStyleFormality),
chatStyleConstraints: normalizeString(data.chatStyleConstraints), chatStyleConstraints: normalizeString(data.chatStyleConstraints),

View File

@ -10,6 +10,15 @@ import { JobDetailPanel } from "./JobDetailPanel";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) => const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui); renderWithQueryClient(ui);
const mockSettings = {
settings: null,
error: null,
isLoading: false,
showSponsorInfo: true,
renderMarkdownInJobDescriptions: true,
refreshSettings: vi.fn(),
};
vi.mock("@/components/ui/dropdown-menu", () => { vi.mock("@/components/ui/dropdown-menu", () => {
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => ( DropdownMenu: ({ children }: { children: React.ReactNode }) => (
@ -51,6 +60,10 @@ vi.mock("@client/components", () => ({
TailoredSummary: () => <div data-testid="tailored-summary" />, TailoredSummary: () => <div data-testid="tailored-summary" />,
})); }));
vi.mock("@client/hooks/useSettings", () => ({
useSettings: () => mockSettings,
}));
vi.mock("@client/components/ReadyPanel", () => ({ vi.mock("@client/components/ReadyPanel", () => ({
ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => ( ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
<div> <div>
@ -146,6 +159,7 @@ const renderJobDetailPanel = async (
describe("JobDetailPanel", () => { describe("JobDetailPanel", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockSettings.renderMarkdownInJobDescriptions = true;
}); });
it("renders the discovered panel when active tab is discovered", async () => { it("renders the discovered panel when active tab is discovered", async () => {
@ -188,6 +202,51 @@ describe("JobDetailPanel", () => {
expect(screen.getByText("Hello world")).toBeInTheDocument(); 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 () => { it("saves an edited description", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined); const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.updateJob).mockResolvedValue(undefined as any); vi.mocked(api.updateJob).mockResolvedValue(undefined as any);

View File

@ -13,6 +13,7 @@ import {
useSkipJobMutation, useSkipJobMutation,
} from "@client/hooks/queries/useJobMutations"; } from "@client/hooks/queries/useJobMutations";
import { useProfile } from "@client/hooks/useProfile"; import { useProfile } from "@client/hooks/useProfile";
import { useSettings } from "@client/hooks/useSettings";
import type { Job, JobListItem } from "@shared/types.js"; import type { Job, JobListItem } from "@shared/types.js";
import { import {
CheckCircle2, CheckCircle2,
@ -28,9 +29,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } 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 { toast } from "sonner";
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -46,7 +47,6 @@ import {
copyTextToClipboard, copyTextToClipboard,
formatJobForWebhook, formatJobForWebhook,
safeFilenamePart, safeFilenamePart,
stripHtml,
} from "@/lib/utils"; } from "@/lib/utils";
import type { FilterTab } from "./constants"; import type { FilterTab } from "./constants";
@ -82,6 +82,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const skipJobMutation = useSkipJobMutation(); const skipJobMutation = useSkipJobMutation();
const { personName } = useProfile(); const { personName } = useProfile();
const { renderMarkdownInJobDescriptions } = useSettings();
const handleTailoringDirtyChange = useCallback( const handleTailoringDirtyChange = useCallback(
(isDirty: boolean) => { (isDirty: boolean) => {
@ -105,10 +106,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
}, [onPauseRefreshChange]); }, [onPauseRefreshChange]);
const description = useMemo(() => { const description = useMemo(() => {
if (!selectedJob?.jobDescription) return "No description available."; return getRenderableJobDescription(selectedJob?.jobDescription);
const jd = selectedJob.jobDescription;
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
return jd;
}, [selectedJob]); }, [selectedJob]);
useEffect(() => { useEffect(() => {
@ -783,11 +781,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
</Button> </Button>
</div> </div>
</div> </div>
) : renderMarkdownInJobDescriptions ? (
<JobDescriptionMarkdown description={description} />
) : ( ) : (
<div className="whitespace-pre-wrap leading-relaxed"> <div className="whitespace-pre-wrap leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{description} {description}
</ReactMarkdown>
</div> </div>
)} )}
</div> </div>

View File

@ -21,10 +21,7 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
isLoading, isLoading,
isSaving, isSaving,
}) => { }) => {
const { const { showSponsorInfo, renderMarkdownInJobDescriptions } = values;
default: defaultShowSponsorInfo,
effective: effectiveShowSponsorInfo,
} = values;
const { control } = useFormContext<UpdateSettingsInput>(); const { control } = useFormContext<UpdateSettingsInput>();
return ( return (
@ -41,7 +38,7 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
render={({ field }) => ( render={({ field }) => (
<Checkbox <Checkbox
id="showSponsorInfo" id="showSponsorInfo"
checked={field.value ?? defaultShowSponsorInfo} checked={field.value ?? showSponsorInfo.default}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
field.onChange( field.onChange(
checked === "indeterminate" ? null : checked === true, checked === "indeterminate" ? null : checked === true,
@ -68,17 +65,77 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
<Separator /> <Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2"> <div className="flex items-start space-x-3">
<Controller
name="renderMarkdownInJobDescriptions"
control={control}
render={({ field }) => (
<Checkbox
id="renderMarkdownInJobDescriptions"
checked={
field.value ?? renderMarkdownInJobDescriptions.default
}
onCheckedChange={(checked) => {
field.onChange(
checked === "indeterminate" ? null : checked === true,
);
}}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="renderMarkdownInJobDescriptions"
className="text-sm font-medium leading-none cursor-pointer"
>
Render Markdown in job descriptions
</label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
</div>
<Separator />
<div className="grid gap-3 text-sm sm:grid-cols-2">
<div> <div>
<div className="text-xs text-muted-foreground">Effective</div> <div className="text-xs text-muted-foreground">
Sponsor info effective
</div>
<div className="break-words font-mono text-xs"> <div className="break-words font-mono text-xs">
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"} {showSponsorInfo.effective ? "Enabled" : "Disabled"}
</div> </div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Default</div> <div className="text-xs text-muted-foreground">
Sponsor info default
</div>
<div className="break-words font-mono text-xs font-semibold"> <div className="break-words font-mono text-xs font-semibold">
{defaultShowSponsorInfo ? "Enabled" : "Disabled"} {showSponsorInfo.default ? "Enabled" : "Disabled"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Markdown rendering effective
</div>
<div className="break-words font-mono text-xs">
{renderMarkdownInJobDescriptions.effective
? "Enabled"
: "Disabled"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Markdown rendering default
</div>
<div className="break-words font-mono text-xs font-semibold">
{renderMarkdownInJobDescriptions.default
? "Enabled"
: "Disabled"}
</div> </div>
</div> </div>
</div> </div>

View File

@ -18,7 +18,10 @@ export type ModelValues = EffectiveDefault<string> & {
}; };
export type WebhookValues = EffectiveDefault<string>; export type WebhookValues = EffectiveDefault<string>;
export type DisplayValues = EffectiveDefault<boolean>; export type DisplayValues = {
showSponsorInfo: EffectiveDefault<boolean>;
renderMarkdownInJobDescriptions: EffectiveDefault<boolean>;
};
export type ChatValues = { export type ChatValues = {
tone: EffectiveDefault<string>; tone: EffectiveDefault<string>;
formality: EffectiveDefault<string>; formality: EffectiveDefault<string>;

View File

@ -21,6 +21,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
"full stack engineer", "full stack engineer",
]), ]),
showSponsorInfo: "1", showSponsorInfo: "1",
renderMarkdownInJobDescriptions: "1",
backupEnabled: "0", backupEnabled: "0",
backupHour: "2", backupHour: "2",
backupMaxCount: "5", backupMaxCount: "5",

View File

@ -57,6 +57,12 @@ describe("settingsRegistry helpers", () => {
expect(settingsRegistry.showSponsorInfo.parse("false")).toBe(false); expect(settingsRegistry.showSponsorInfo.parse("false")).toBe(false);
expect(settingsRegistry.showSponsorInfo.parse("")).toBeNull(); expect(settingsRegistry.showSponsorInfo.parse("")).toBeNull();
expect(settingsRegistry.showSponsorInfo.parse(undefined)).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", () => { it("serializes bit bools correctly", () => {
@ -64,6 +70,12 @@ describe("settingsRegistry helpers", () => {
expect(settingsRegistry.showSponsorInfo.serialize(false)).toBe("0"); expect(settingsRegistry.showSponsorInfo.serialize(false)).toBe("0");
expect(settingsRegistry.showSponsorInfo.serialize(null)).toBeNull(); expect(settingsRegistry.showSponsorInfo.serialize(null)).toBeNull();
expect(settingsRegistry.showSponsorInfo.serialize(undefined)).toBeNull(); expect(settingsRegistry.showSponsorInfo.serialize(undefined)).toBeNull();
expect(
settingsRegistry.renderMarkdownInJobDescriptions.serialize(true),
).toBe("1");
expect(
settingsRegistry.renderMarkdownInJobDescriptions.serialize(false),
).toBe("0");
}); });
}); });

View File

@ -371,6 +371,13 @@ export const settingsRegistry = {
parse: parseBitBoolOrNull, parse: parseBitBoolOrNull,
serialize: serializeBitBool, serialize: serializeBitBool,
}, },
renderMarkdownInJobDescriptions: {
kind: "typed" as const,
schema: z.boolean(),
default: (): boolean => true,
parse: parseBitBoolOrNull,
serialize: serializeBitBool,
},
chatStyleTone: { chatStyleTone: {
kind: "typed" as const, kind: "typed" as const,
schema: z.string().trim().max(100), schema: z.string().trim().max(100),

View File

@ -186,6 +186,11 @@ export const createAppSettings = (
override: null, override: null,
}, },
showSponsorInfo: { value: true, default: true, override: null }, showSponsorInfo: { value: true, default: true, override: null },
renderMarkdownInJobDescriptions: {
value: true,
default: true,
override: null,
},
chatStyleTone: { chatStyleTone: {
value: "professional", value: "professional",
default: "professional", default: "professional",

View File

@ -162,6 +162,7 @@ export interface AppSettings {
jobspyResultsWanted: Resolved<number>; jobspyResultsWanted: Resolved<number>;
jobspyCountryIndeed: Resolved<string>; jobspyCountryIndeed: Resolved<string>;
showSponsorInfo: Resolved<boolean>; showSponsorInfo: Resolved<boolean>;
renderMarkdownInJobDescriptions: Resolved<boolean>;
chatStyleTone: Resolved<string>; chatStyleTone: Resolved<string>;
chatStyleFormality: Resolved<string>; chatStyleFormality: Resolved<string>;
chatStyleConstraints: Resolved<string>; chatStyleConstraints: Resolved<string>;