Merge remote-tracking branch 'origin/main' into feat/applications

This commit is contained in:
Amruth Pillai
2026-07-05 15:17:23 +02:00
78 changed files with 2874 additions and 1880 deletions
@@ -0,0 +1,183 @@
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
import type { ReactElement, ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
import {
CircleNotchIcon,
DownloadSimpleIcon,
EnvelopeSimpleIcon,
FileDocIcon,
FileJsIcon,
FilePdfIcon,
FileTextIcon,
MarkdownLogoIcon,
} from "@phosphor-icons/react";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@reactive-resume/ui/components/dialog";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { cn } from "@reactive-resume/utils/style";
import { useResumeExport } from "./use-resume-export";
type DownloadableResume = Parameters<typeof useResumeExport>[0];
type ResumeDownloadDialogProps = {
resume: DownloadableResume;
trigger: (disabled: boolean) => ReactElement;
};
type FormatRowProps = {
action: ReactElement;
description: ReactNode;
disabled?: boolean;
icon: ReactElement;
title: ReactNode;
};
function FormatRow({ action, description, disabled, icon, title }: FormatRowProps) {
return (
<div
className={cn(
"flex items-center gap-3 rounded-lg border bg-background p-3 transition-opacity",
disabled && "opacity-45",
)}
>
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground">
{icon}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h3 className="font-medium text-sm">{title}</h3>
<p className="text-muted-foreground text-xs leading-normal">{description}</p>
</div>
{action}
</div>
);
}
export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogProps) {
const [open, setOpen] = useState(false);
const [scope, setScope] = useState<ResumeExportTarget>("resume");
const { hasCoverLetter, isExporting, onDownloadDOCX, onDownloadJSON, onDownloadMarkdown, onDownloadPDF } =
useResumeExport(resume);
const disabled = !resume || isExporting;
// Cover letter can't be the active scope when the resume has none (also guards a stale toggle).
const activeScope: ResumeExportTarget = scope === "cover-letter" && !hasCoverLetter ? "resume" : scope;
const jsonDisabled = activeScope === "cover-letter";
const run = (action: () => void | Promise<void>) => {
setOpen(false);
void action();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger(disabled)} />
<DialogContent className="gap-5 sm:max-w-lg">
<DialogHeader className="pe-8">
<DialogTitle>
<Trans>Download</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Export your resume or cover letter in the format you need.</Trans>
</DialogDescription>
</DialogHeader>
<Tabs value={activeScope} onValueChange={(value) => setScope(value as ResumeExportTarget)}>
<TabsList className="h-11! w-full">
<TabsTrigger value="resume">
<FileTextIcon />
<Trans>Resume</Trans>
</TabsTrigger>
<TabsTrigger value="cover-letter" disabled={!hasCoverLetter}>
<EnvelopeSimpleIcon />
<Trans>Cover letter</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid gap-2">
<FormatRow
icon={
isExporting ? <CircleNotchIcon className="size-5 animate-spin" /> : <FilePdfIcon className="size-5" />
}
title="PDF"
description={<Trans>Best for applications, sharing, and printing.</Trans>}
action={
<Button
size="sm"
aria-label="Download PDF"
disabled={isExporting}
onClick={() => run(() => onDownloadPDF(activeScope))}
>
<DownloadSimpleIcon />
<Trans>Download</Trans>
</Button>
}
/>
<FormatRow
icon={<FileDocIcon className="size-5" />}
title="DOCX"
description={<Trans>Editable in Word, Google Docs, and Pages.</Trans>}
action={
<Button
size="sm"
variant="outline"
aria-label="Download DOCX"
disabled={isExporting}
onClick={() => run(() => onDownloadDOCX(activeScope))}
>
<DownloadSimpleIcon />
<Trans>Download</Trans>
</Button>
}
/>
<FormatRow
icon={<MarkdownLogoIcon className="size-5" />}
title="Markdown"
description={<Trans>Plain text, ideal for AI tools and quick edits.</Trans>}
action={
<Button
size="sm"
variant="outline"
aria-label="Download Markdown"
disabled={isExporting}
onClick={() => run(() => onDownloadMarkdown(activeScope))}
>
<DownloadSimpleIcon />
<Trans>Download</Trans>
</Button>
}
/>
<FormatRow
disabled={jsonDisabled}
icon={<FileJsIcon className="size-5" />}
title="JSON"
description={<Trans>Full resume data for backup or import.</Trans>}
action={
<Button
size="sm"
variant="outline"
aria-label="Download JSON"
disabled={jsonDisabled}
onClick={() => run(onDownloadJSON)}
>
<DownloadSimpleIcon />
<Trans>Download</Trans>
</Button>
}
/>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,11 +1,27 @@
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { buildDocx } from "@reactive-resume/docx";
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resume/export-sections";
import { buildMarkdown } from "@reactive-resume/resume/markdown";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
import { createResumePdfBlob } from "./pdf-document";
/**
* Section titles are stored empty by default and resolved (locale-aware) at render time. PDF does
* this via an injected resolver; DOCX and Markdown reuse the same resolution here so their section
* headings aren't blank. Returns a `(sectionId) => title` function.
*/
const createSectionTitleResolver = async (data: ResumeData) => {
const resolveSectionTitle = await createSectionTitleResolverForLocale(data.metadata.page.locale);
const dataWithResolver = { ...data, resolveSectionTitle };
return (sectionId: string) => getResumeSectionTitle(dataWithResolver, sectionId);
};
// ponytail: loosened from Resume to Pick so public-resume (where name may be "" for non-owners) can reuse
type ExportableResume = {
name: string;
@@ -14,6 +30,8 @@ type ExportableResume = {
};
const getExportName = (resume: ExportableResume) => resume.name || resume.data.basics.name || resume.slug;
const getTargetExportName = (resume: ExportableResume, target: ResumeExportTarget) =>
target === "cover-letter" ? `${getExportName(resume)} Cover Letter` : getExportName(resume);
/**
* Single source of truth for resume export (PDF / DOCX / JSON / Print). Previously duplicated verbatim
@@ -21,6 +39,7 @@ const getExportName = (resume: ExportableResume) => resume.name || resume.data.b
*/
export function useResumeExport(resume: ExportableResume | undefined) {
const [isExporting, setIsExporting] = useState(false);
const hasCoverLetter = resume ? resumeHasCoverLetter(resume.data) : false;
const onDownloadJSON = useCallback(() => {
if (!resume) return;
@@ -28,30 +47,53 @@ export function useResumeExport(resume: ExportableResume | undefined) {
downloadWithAnchor(blob, generateFilename(getExportName(resume), "json"));
}, [resume]);
const onDownloadDOCX = useCallback(async () => {
if (!resume) return;
try {
const blob = await buildDocx(resume.data);
downloadWithAnchor(blob, generateFilename(getExportName(resume), "docx"));
} catch {
toast.error(t`There was a problem while generating the DOCX, please try again.`);
}
}, [resume]);
const onDownloadMarkdown = useCallback(
async (target: ResumeExportTarget = "resume") => {
if (!resume) return;
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
const data = getResumeExportData(resume.data, target);
const resolveTitle = await createSectionTitleResolver(data);
const blob = new Blob([buildMarkdown(data, resolveTitle)], { type: "text/markdown" });
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "md"));
},
[resume],
);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
setIsExporting(true);
try {
const blob = await createResumePdfBlob(resume.data);
downloadWithAnchor(blob, generateFilename(getExportName(resume), "pdf"));
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
} finally {
setIsExporting(false);
toast.dismiss(toastId);
}
}, [resume]);
const onDownloadDOCX = useCallback(
async (target: ResumeExportTarget = "resume") => {
if (!resume) return;
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
try {
const data = getResumeExportData(resume.data, target);
const resolveTitle = await createSectionTitleResolver(data);
const blob = await buildDocx(data, resolveTitle);
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "docx"));
} catch {
toast.error(t`There was a problem while generating the DOCX, please try again.`);
}
},
[resume],
);
const onDownloadPDF = useCallback(
async (target: ResumeExportTarget = "resume") => {
if (!resume) return;
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
setIsExporting(true);
try {
const data = getResumeExportData(resume.data, target);
const blob = await createResumePdfBlob(data);
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
} finally {
setIsExporting(false);
toast.dismiss(toastId);
}
},
[resume],
);
const onPrint = useCallback(async () => {
if (!resume) return;
@@ -86,5 +128,5 @@ export function useResumeExport(resume: ExportableResume | undefined) {
}
}, [resume]);
return { onDownloadJSON, onDownloadDOCX, onDownloadPDF, onPrint, isExporting };
return { onDownloadJSON, onDownloadMarkdown, onDownloadDOCX, onDownloadPDF, onPrint, isExporting, hasCoverLetter };
}
@@ -33,7 +33,7 @@ export function PublicResumeRoute() {
{basics.name && <h1 className="font-semibold text-2xl tracking-tight">{basics.name}</h1>}
{basics.headline && <p className="text-muted-foreground">{basics.headline}</p>}
</div>
<Button onClick={onDownloadPDF} disabled={isExporting}>
<Button onClick={() => void onDownloadPDF()} disabled={isExporting}>
{isExporting ? (
<CircleNotchIcon className="size-4 animate-spin" />
) : (
@@ -62,7 +62,7 @@ export function PublicResumeRoute() {
size="icon-lg"
variant="outline"
disabled={isExporting}
onClick={onDownloadPDF}
onClick={() => void onDownloadPDF()}
aria-label={t`Download PDF`}
title={t`Download PDF`}
className="fixed right-6 bottom-6 z-50 rounded-full bg-background/95 opacity-70 shadow-lg backdrop-blur transition-opacity hover:opacity-100 print:hidden"
@@ -6,13 +6,10 @@ import {
CircleNotchIcon,
CopySimpleIcon,
DownloadSimpleIcon,
FileDocIcon,
FileJsIcon,
HouseSimpleIcon,
LockSimpleIcon,
LockSimpleOpenIcon,
PencilSimpleLineIcon,
PrinterIcon,
SidebarSimpleIcon,
TrashSimpleIcon,
WarningCircleIcon,
@@ -31,7 +28,7 @@ import {
} from "@reactive-resume/ui/components/dropdown-menu";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, usePatchResume, useResumeStore } from "@/features/resume/builder/draft";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -103,63 +100,31 @@ export function BuilderHeader() {
function ResumeDownloadButton() {
const resume = useCurrentResume();
const { onDownloadPDF, onDownloadDOCX, onDownloadJSON, onPrint, isExporting } = useResumeExport(resume);
return (
<div className="flex items-center">
<Button
size="sm"
aria-label={t({
comment: "Primary action in the builder header to download the resume as a PDF",
message: "Download PDF",
})}
className="rounded-e-none px-2 sm:px-2.5"
disabled={isExporting}
onClick={onDownloadPDF}
>
{isExporting ? (
<CircleNotchIcon className="animate-spin sm:me-1.5" />
) : (
<DownloadSimpleIcon className="sm:me-1.5" />
)}
<span className="hidden sm:inline">
<Trans comment="Primary action in the builder header to download the resume as a PDF">Download PDF</Trans>
</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="sm"
disabled={isExporting}
aria-label={t`More download options`}
className="rounded-s-none border-primary-foreground/20 border-s px-1.5"
>
<CaretDownIcon />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onDownloadDOCX}>
<FileDocIcon className="me-2" />
<Trans>Download DOCX</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDownloadJSON}>
<FileJsIcon className="me-2" />
<Trans>Download JSON</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onPrint}>
<PrinterIcon className="me-2" />
<Trans>Print</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ResumeDownloadDialog
resume={resume}
trigger={(disabled) => (
<Button
size="sm"
aria-label={t({
comment: "Primary action in the builder header to open resume download options",
message: "Download options",
})}
disabled={disabled}
className="px-2 sm:px-2.5"
>
{disabled ? (
<CircleNotchIcon className="animate-spin sm:me-1.5" />
) : (
<DownloadSimpleIcon className="sm:me-1.5" />
)}
<span className="hidden sm:inline">
<Trans comment="Primary action in the builder header to open resume download options">Download</Trans>
</span>
</Button>
)}
/>
);
}
@@ -1,14 +1,25 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const downloadWithAnchor = vi.hoisted(() => vi.fn());
const buildDocx = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/x-docx" })));
const createResumePdfBlob = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/pdf" })));
const resumeMock = vi.hoisted(() => ({
resume: undefined as
| undefined
| {
id: string;
name: string;
slug: string;
data: typeof defaultResumeData;
},
}));
type SectionBaseProps = {
children: React.ReactNode;
@@ -23,8 +34,13 @@ vi.mock("@reactive-resume/utils/file", () => ({
}));
vi.mock("@reactive-resume/docx", () => ({ buildDocx }));
vi.mock("@/features/resume/export/pdf-document", () => ({ createResumePdfBlob }));
// DOCX/Markdown resolve locale-aware section titles; stub the async locale resolver so exports
// fall back to the built-in English titles without loading real locale catalogs.
vi.mock("@/libs/resume/section-title-locale", () => ({
createSectionTitleResolverForLocale: vi.fn().mockResolvedValue(() => undefined),
}));
vi.mock("@/features/resume/builder/draft", () => ({
useResume: () => ({ id: "r1", name: "My Resume", data: defaultResumeData }),
useResume: () => resumeMock.resume,
}));
const { ExportSectionBuilder } = await import("./export");
@@ -33,6 +49,10 @@ beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: defaultResumeData };
});
afterEach(() => {
downloadWithAnchor.mockReset();
buildDocx.mockClear();
@@ -46,18 +66,40 @@ const renderExport = () =>
</I18nProvider>,
);
const openDialog = () => {
const trigger = screen.getByText(
"Choose PDF, DOCX, or JSON. Export your resume and cover letter separately when available.",
);
fireEvent.click(trigger.closest("button") as HTMLButtonElement);
};
describe("ExportSectionBuilder", () => {
it("renders JSON, DOCX, and PDF action buttons", () => {
it("renders the PDF, DOCX, Markdown, and JSON format rows", () => {
renderExport();
expect(screen.getByText("JSON")).toBeInTheDocument();
expect(screen.getByText("DOCX")).toBeInTheDocument();
expect(screen.getByText("PDF")).toBeInTheDocument();
openDialog();
expect(screen.getByRole("button", { name: "Download PDF" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Download DOCX" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Download Markdown" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Download JSON" })).toBeInTheDocument();
});
it("downloads a Markdown blob when the Markdown button is clicked", async () => {
renderExport();
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download Markdown" }));
await waitFor(() => expect(downloadWithAnchor).toHaveBeenCalledTimes(1));
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the download call exists before destructuring it.
const [blob, filename] = downloadWithAnchor.mock.calls[0]!;
expect((blob as Blob).type).toBe("text/markdown");
expect(filename).toBe("My Resume.md");
});
it("downloads a JSON blob when the JSON button is clicked", () => {
renderExport();
const button = screen.getByText("JSON").closest("button") as HTMLButtonElement;
fireEvent.click(button);
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the download call exists before destructuring it.
@@ -69,23 +111,18 @@ describe("ExportSectionBuilder", () => {
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
renderExport();
const button = screen.getByText("DOCX").closest("button") as HTMLButtonElement;
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download DOCX" }));
fireEvent.click(button);
// Wait for the async callback chain to settle.
await Promise.resolve();
await Promise.resolve();
expect(buildDocx).toHaveBeenCalledTimes(1);
await waitFor(() => expect(buildDocx).toHaveBeenCalledTimes(1));
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.docx");
});
it("calls createResumePdfBlob and downloads when PDF is clicked", async () => {
renderExport();
const button = screen.getByText("PDF").closest("button") as HTMLButtonElement;
fireEvent.click(button);
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download PDF" }));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
@@ -94,4 +131,19 @@ describe("ExportSectionBuilder", () => {
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
});
it("exports the cover letter when the scope is switched and a cover letter exists", async () => {
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: sampleResumeData };
renderExport();
openDialog();
fireEvent.click(screen.getByRole("tab", { name: "Cover letter" }));
fireEvent.click(screen.getByRole("button", { name: "Download PDF" }));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume Cover Letter.pdf");
});
});
@@ -1,74 +1,37 @@
import { Trans } from "@lingui/react/macro";
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
import { DownloadSimpleIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { useResume } from "@/features/resume/builder/draft";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
import { SectionBase } from "../shared/section-base";
export function ExportSectionBuilder() {
const resume = useResume();
const { onDownloadJSON, onDownloadDOCX, onDownloadPDF, isExporting } = useResumeExport(resume);
if (!resume) return null;
return (
<SectionBase type="export" className="space-y-4">
<Button
variant="outline"
onClick={onDownloadJSON}
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
>
<FileJsIcon className="size-6 shrink-0" />
<div className="flex flex-1 flex-col gap-y-1">
<h6 className="font-medium">JSON</h6>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Download a copy of your resume in JSON format. Use this file for backup or to import your resume into
other applications, including AI assistants.
</Trans>
</p>
</div>
</Button>
<Button
variant="outline"
onClick={onDownloadDOCX}
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
>
<FileDocIcon className="size-6 shrink-0" />
<div className="flex flex-1 flex-col gap-y-1">
<h6 className="font-medium">DOCX</h6>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Download a copy of your resume as a Word document. Use this file to further customize your resume in
Microsoft Word or Google Docs.
</Trans>
</p>
</div>
</Button>
<Button
variant="outline"
disabled={isExporting}
onClick={onDownloadPDF}
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
>
{isExporting ? (
<CircleNotchIcon className="size-6 shrink-0 animate-spin" />
) : (
<FilePdfIcon className="size-6 shrink-0" />
<ResumeDownloadDialog
resume={resume}
trigger={(disabled) => (
<Button
variant="outline"
disabled={disabled}
className="h-auto w-full gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
>
<DownloadSimpleIcon className="size-6 shrink-0" />
<div className="flex flex-1 flex-col gap-y-1">
<h6 className="font-medium">
<Trans>Download</Trans>
</h6>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>Choose PDF, DOCX, or JSON. Export your resume and cover letter separately when available.</Trans>
</p>
</div>
</Button>
)}
<div className="flex flex-1 flex-col gap-y-1">
<h6 className="font-medium">PDF</h6>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Download a copy of your resume in PDF format. Use this file for printing or to easily share your resume
with recruiters.
</Trans>
</p>
</div>
</Button>
/>
</SectionBase>
);
}