feat: add hide link underline option to resume settings, resolves #3134

This commit is contained in:
Amruth Pillai
2026-06-01 15:30:49 +02:00
parent d6a9bc6c4b
commit 5fb4976ec9
16 changed files with 2820 additions and 157 deletions
@@ -258,6 +258,30 @@ function PageSectionForm() {
)}
</form.Field>
<form.Field name="hideLinkUnderline">
{(field) => (
<FormItem
className="col-span-full flex items-center gap-x-3 py-1"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked) => {
field.handleChange(checked);
handleAutoSave("hideLinkUnderline", checked);
}}
/>
}
/>
<FormLabel>
<Trans>Hide Link Underline</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.Field name="hideIcons">
{(field) => (
<FormItem
@@ -227,6 +227,10 @@ describe("ReactiveResumeV4JSONImporter — broad section mapping", () => {
expect(result.picture.borderWidth).toBeGreaterThan(0);
});
it("maps v4 underline link preference to the v5 hide underline page setting", () => {
expect(result.metadata.page.hideLinkUnderline).toBe(true);
});
it("maps summary content", () => {
expect(result.summary.content).toBe("<p>About me</p>");
expect(result.summary.hidden).toBe(false);
@@ -744,6 +744,7 @@ export class ReactiveResumeV4JSONImporter {
marginY: nonNegative(v4Data.metadata.page?.margin ?? 14),
format: v4Data.metadata.page?.format ?? "a4",
locale: "en-US",
hideLinkUnderline: v4Data.metadata.typography?.underlineLinks === false,
hideIcons: v4Data.metadata.typography?.hideIcons ?? false,
hideSectionIcons: true,
},
@@ -4,6 +4,13 @@ import { describe, expect, it } from "vitest";
const source = readFileSync(fileURLToPath(new URL("./primitives.tsx", import.meta.url)), "utf8");
describe("Link", () => {
it("passes the resume page underline preference to shared link styles", () => {
expect(source).toContain("metadata.page.hideLinkUnderline");
expect(source).toContain("hideUnderline: metadata.page.hideLinkUnderline");
});
});
describe("SectionHeadingIcon", () => {
it("passes the resolved heading icon size through the icon size prop", () => {
expect(source).toContain("size: sizeProp");
@@ -34,10 +34,22 @@ export const Heading = ({ style, ...props }: ComponentProps<typeof PdfText>) =>
};
export const Link = ({ style, ...props }: ComponentProps<typeof PdfLink>) => {
const { metadata } = useRender();
const linkStyle = useTemplateStyle("link");
const linkRuleStyle = useSectionStyleRule("link");
return <PdfLink style={composeLinkStyles(linkStyle, linkRuleStyle, asStyleInput(style), safeTextStyle)} {...props} />;
return (
<PdfLink
style={composeLinkStyles(
{ hideUnderline: metadata.page.hideLinkUnderline },
linkStyle,
linkRuleStyle,
asStyleInput(style),
safeTextStyle,
)}
{...props}
/>
);
};
export const Small = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
@@ -11,6 +11,7 @@ type RichTextProseSpacing = {
type CreateRichTextStylesheetOptions = {
boldStyle?: StyleInput;
hideLinkUnderline?: boolean;
linkStyle?: StyleInput;
richParagraphStyle?: StyleInput;
richParagraphRuleStyle?: StyleInput;
@@ -32,6 +33,7 @@ const emptyProseSpacing: RichTextProseSpacing = {
export const createRichTextStylesheet = ({
boldStyle,
hideLinkUnderline,
linkStyle,
richParagraphStyle,
richParagraphRuleStyle,
@@ -48,5 +50,5 @@ export const createRichTextStylesheet = ({
li: mergeStyles(proseSpacing.listItem),
[`.${richTextMarkClassName}`]: mergeStyles(richMarkStyle, richMarkRuleStyle, safeTextStyle),
p: mergeStyles(richParagraphStyle, richParagraphRuleStyle, safeTextStyle, proseSpacing.paragraph),
a: mergeLinkStyles(linkStyle, richLinkRuleStyle, safeTextStyle),
a: mergeLinkStyles({ hideUnderline: hideLinkUnderline === true }, linkStyle, richLinkRuleStyle, safeTextStyle),
});
@@ -66,4 +66,13 @@ describe("createRichTextStylesheet", () => {
expect(stylesheet.ol).toEqual({ rowGap: 8 });
expect(stylesheet.li).toEqual({ marginTop: 2, marginBottom: 2 });
});
it("can force rich text links to render without underlines", () => {
const stylesheet = createRichTextStylesheet({
hideLinkUnderline: true,
richLinkRuleStyle: { textDecoration: "underline", textDecorationStyle: "dotted" },
});
expect(stylesheet.a).toMatchObject({ textDecoration: "none", textDecorationStyle: "dotted" });
});
});
@@ -58,7 +58,7 @@ const applyRtlDirectionRecursively = (node: ReactNode): ReactNode => {
};
export const RichText = ({ children }: RichTextProps) => {
const { rtl } = useRender();
const { metadata, rtl } = useRender();
const rtlTextWrapStyle: Style | undefined = rtl ? { direction: "rtl", textAlign: "right" } : undefined;
const boldStyle = useTemplateStyle("bold");
const linkStyle = useTemplateStyle("link");
@@ -169,6 +169,7 @@ export const RichText = ({ children }: RichTextProps) => {
}}
stylesheet={createRichTextStylesheet({
boldStyle,
hideLinkUnderline: metadata.page.hideLinkUnderline,
linkStyle,
richParagraphStyle,
richParagraphRuleStyle,
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { composeLinkStyles, mergeLinkStyles } from "./styles";
describe("link styles", () => {
it("underlines links by default", () => {
expect(composeLinkStyles({}, { color: "#111" }).at(-1)).toEqual({ textDecoration: "underline" });
expect(mergeLinkStyles({}, { color: "#111" })).toMatchObject({ color: "#111", textDecoration: "underline" });
});
it("forces links to render without underlines when requested", () => {
expect(composeLinkStyles({ hideUnderline: true }, { textDecoration: "underline" }).at(-1)).toEqual({
textDecoration: "none",
});
expect(mergeLinkStyles({ hideUnderline: true }, { textDecoration: "underline" })).toMatchObject({
textDecoration: "none",
});
});
});
+12 -2
View File
@@ -4,6 +4,10 @@ export type TemplatePlacement = "main" | "sidebar";
export type StyleInput = Style | Style[] | null | undefined;
type LinkStyleOptions = {
hideUnderline?: boolean;
};
export const composeStyles = (...styles: StyleInput[]): Style[] => {
return styles.flatMap((style) => {
if (!style) return [];
@@ -14,12 +18,18 @@ export const composeStyles = (...styles: StyleInput[]): Style[] => {
};
const linkUnderlineStyle = { textDecoration: "underline" } satisfies Style;
const linkNoUnderlineStyle = { textDecoration: "none" } satisfies Style;
export const composeLinkStyles = (...styles: StyleInput[]): Style[] => composeStyles(...styles, linkUnderlineStyle);
const resolveLinkDecorationStyle = ({ hideUnderline = false }: LinkStyleOptions = {}) =>
hideUnderline ? linkNoUnderlineStyle : linkUnderlineStyle;
export const composeLinkStyles = (options: LinkStyleOptions = {}, ...styles: StyleInput[]): Style[] =>
composeStyles(...styles, resolveLinkDecorationStyle(options));
export const mergeStyles = (...styles: StyleInput[]): Style => Object.assign({}, ...composeStyles(...styles));
export const mergeLinkStyles = (...styles: StyleInput[]): Style => mergeStyles(...styles, linkUnderlineStyle);
export const mergeLinkStyles = (options: LinkStyleOptions = {}, ...styles: StyleInput[]): Style =>
mergeStyles(...styles, resolveLinkDecorationStyle(options));
// Increased from 1.2 to 1.3 to prevent descenders (g, p, y, etc.) from being
// clipped by the overflow:hidden applied in safeTextStyle on all Heading elements.
+2713 -152
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -280,6 +280,13 @@ describe("pageSchema", () => {
expect(result.success).toBe(true);
if (result.success) expect(result.data.hideSectionIcons).toBe(true);
});
it("defaults hideLinkUnderline to false when missing", () => {
const { hideLinkUnderline: _, ...pageWithout } = defaultResumeData.metadata.page;
const result = pageSchema.safeParse(pageWithout);
expect(result.success).toBe(true);
if (result.success) expect(result.data.hideLinkUnderline).toBe(false);
});
});
describe("baseSectionSchema", () => {
+1
View File
@@ -469,6 +469,7 @@ export const pageSchema = z.object({
.string()
.describe("The locale of the page. Used for displaying pre-translated section headings, if not overridden.")
.catch("en-US"),
hideLinkUnderline: z.boolean().describe("Whether to hide the underlines of the links.").catch(false),
hideIcons: z.boolean().describe("Whether to hide the item-level icons (skills, profiles, interests).").catch(false),
hideSectionIcons: z
.boolean()
@@ -20,6 +20,10 @@ describe("defaultResumeData", () => {
expect(defaultResumeData.metadata.page.format).toBe("a4");
});
it("shows link underlines by default", () => {
expect(defaultResumeData.metadata.page.hideLinkUnderline).toBe(false);
});
it("starts with no resume content", () => {
expect(defaultResumeData.basics.name).toBe("");
expect(defaultResumeData.summary.content).toBe("");
+1
View File
@@ -135,6 +135,7 @@ export const defaultResumeData: ResumeData = {
marginY: 12,
format: "a4",
locale: "en-US",
hideLinkUnderline: false,
hideIcons: false,
hideSectionIcons: true,
},
+1
View File
@@ -567,6 +567,7 @@ export const sampleResumeData: ResumeData = {
marginY: 16,
format: "a4",
locale: "en-US",
hideLinkUnderline: false,
hideIcons: false,
hideSectionIcons: false,
},