feat: add Meowth template with inline three-column entry header (Asian-style compact ATS) (#2923)

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
JamesGoslings
2026-04-27 14:45:08 +08:00
committed by GitHub
parent b26d510294
commit 54e256be09
67 changed files with 586 additions and 95 deletions
+2
View File
@@ -28,6 +28,7 @@ import { GlalieTemplate } from "./templates/glalie";
import { KakunaTemplate } from "./templates/kakuna";
import { LaprasTemplate } from "./templates/lapras";
import { LeafishTemplate } from "./templates/leafish";
import { MeowthTemplate } from "./templates/meowth";
import { OnyxTemplate } from "./templates/onyx";
import { PikachuTemplate } from "./templates/pikachu";
import { RhyhornTemplate } from "./templates/rhyhorn";
@@ -72,6 +73,7 @@ function getTemplateComponent(template: Template) {
.with("kakuna", () => KakunaTemplate)
.with("lapras", () => LaprasTemplate)
.with("leafish", () => LeafishTemplate)
.with("meowth", () => MeowthTemplate)
.with("onyx", () => OnyxTemplate)
.with("pikachu", () => PikachuTemplate)
.with("rhyhorn", () => RhyhornTemplate)
@@ -29,18 +29,46 @@ import { VolunteerItem } from "./items/volunteer-item";
import { PageSection } from "./page-section";
import { PageSummary } from "./page-summary";
/**
* Extra props forwarded by a template to each rendered item. Used by templates
* (e.g., Meowth) to opt-in to alternative item renderings such as an inline
* three-column header. Only item components that declare the corresponding
* prop will react to it; others simply ignore the extra props via spread.
*/
type ItemProps = {
headerLayout?: "split" | "inline";
};
type SectionComponentProps = {
sectionClassName?: string;
itemClassName?: string;
itemProps?: ItemProps;
};
// Helper to render item component based on type
function renderItemByType(type: CustomSectionType, item: CustomSectionItem, itemClassName?: string) {
function renderItemByType(
type: CustomSectionType,
item: CustomSectionItem,
itemClassName?: string,
itemProps?: ItemProps,
) {
return match(type)
.with("summary", () => <SummaryItem {...(item as SummaryItemType)} className={itemClassName} />)
.with("profiles", () => <ProfilesItem {...(item as SectionItem<"profiles">)} className={itemClassName} />)
.with("experience", () => <ExperienceItem {...(item as SectionItem<"experience">)} className={itemClassName} />)
.with("education", () => <EducationItem {...(item as SectionItem<"education">)} className={itemClassName} />)
.with("experience", () => (
<ExperienceItem
{...(item as SectionItem<"experience">)}
className={itemClassName}
headerLayout={itemProps?.headerLayout}
/>
))
.with("education", () => (
<EducationItem
{...(item as SectionItem<"education">)}
className={itemClassName}
headerLayout={itemProps?.headerLayout}
/>
))
.with("projects", () => <ProjectsItem {...(item as SectionItem<"projects">)} className={itemClassName} />)
.with("skills", () => <SkillsItem {...(item as SectionItem<"skills">)} className={itemClassName} />)
.with("languages", () => <LanguagesItem {...(item as SectionItem<"languages">)} className={itemClassName} />)
@@ -52,7 +80,13 @@ function renderItemByType(type: CustomSectionType, item: CustomSectionItem, item
.with("publications", () => (
<PublicationsItem {...(item as SectionItem<"publications">)} className={itemClassName} />
))
.with("volunteer", () => <VolunteerItem {...(item as SectionItem<"volunteer">)} className={itemClassName} />)
.with("volunteer", () => (
<VolunteerItem
{...(item as SectionItem<"volunteer">)}
className={itemClassName}
headerLayout={itemProps?.headerLayout}
/>
))
.with("references", () => <ReferencesItem {...(item as SectionItem<"references">)} className={itemClassName} />)
.with("cover-letter", () => <CoverLetterItem {...(item as CoverLetterItemType)} className={itemClassName} />)
.exhaustive();
@@ -62,7 +96,7 @@ type SectionProps = { id: string };
export function getSectionComponent(
section: "summary" | SectionType | (string & {}),
{ sectionClassName, itemClassName }: SectionComponentProps = {},
{ sectionClassName, itemClassName, itemProps }: SectionComponentProps = {},
) {
return match(section)
.with("summary", () => {
@@ -82,7 +116,7 @@ export function getSectionComponent(
.with("experience", () => {
const ExperienceSection = (_: SectionProps) => (
<PageSection type="experience" className={sectionClassName}>
{(item) => <ExperienceItem {...item} className={itemClassName} />}
{(item) => <ExperienceItem {...item} className={itemClassName} headerLayout={itemProps?.headerLayout} />}
</PageSection>
);
@@ -91,7 +125,7 @@ export function getSectionComponent(
.with("education", () => {
const EducationSection = (_: SectionProps) => (
<PageSection type="education" className={sectionClassName}>
{(item) => <EducationItem {...item} className={itemClassName} />}
{(item) => <EducationItem {...item} className={itemClassName} headerLayout={itemProps?.headerLayout} />}
</PageSection>
);
@@ -163,7 +197,7 @@ export function getSectionComponent(
.with("volunteer", () => {
const VolunteerSection = (_: SectionProps) => (
<PageSection type="volunteer" className={sectionClassName}>
{(item) => <VolunteerItem {...item} className={itemClassName} />}
{(item) => <VolunteerItem {...item} className={itemClassName} headerLayout={itemProps?.headerLayout} />}
</PageSection>
);
@@ -205,7 +239,7 @@ export function getSectionComponent(
key={item.id}
className={cn(`section-item section-item-${customSection.type} print:break-inside-avoid`)}
>
{renderItemByType(customSection.type, item, itemClassName)}
{renderItemByType(customSection.type, item, itemClassName, itemProps)}
</div>
))}
</div>
@@ -0,0 +1,35 @@
import { cn } from "@/utils/style";
type InlineHeaderProps = {
/** Leading (start) column — typically the position/major. */
leading?: React.ReactNode;
/** Middle column — typically the company/school (primary title). */
middle?: React.ReactNode;
/** Trailing (end) column — typically the period/date. Kept at auto width. */
trailing?: React.ReactNode;
className?: string;
};
/**
* A three-column inline header used by templates that prefer a compact,
* single-line entry header (common in Asian resume conventions where
* position/major · organization · period are traditionally laid out on
* the same line).
*
* Layout: [leading] [middle] [trailing]
* - leading / middle: flexible, `minmax(0, 1fr)` so they shrink/grow together
* and wrap naturally when content is too long to fit on one line. Wrapping
* preserves the three-column alignment because all columns share the same
* grid row and are top-aligned via `items-start`.
* - trailing: `auto` width, end-aligned — suitable for short strings like
* periods/dates that should stay on one line.
*/
export function InlineHeader({ leading, middle, trailing, className }: InlineHeaderProps) {
return (
<div className={cn("grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-start gap-x-3", className)}>
<div className="min-w-0">{leading}</div>
<div className="min-w-0">{middle}</div>
<div className="shrink-0 text-end">{trailing}</div>
</div>
);
}
@@ -5,14 +5,24 @@ import { filterFieldValues } from "@/utils/field";
import { stripHtml } from "@/utils/string";
import { cn } from "@/utils/style";
import { InlineHeader } from "../inline-header";
import { LinkedTitle } from "../linked-title";
import { PageLink } from "../page-link";
type EducationItemProps = SectionItem<"education"> & {
className?: string;
/**
* Controls the header layout of each entry.
* - `"split"` (default): two-column layout — school/area on the start side,
* degree/location-period on the end side.
* - `"inline"`: single-row, three-column layout — `[area (degree · grade)]`,
* `[school]`, `[period]`. Used by templates targeting compact single-line
* entry headers (e.g., Asian resume conventions).
*/
headerLayout?: "split" | "inline";
};
export function EducationItem({ className, ...item }: EducationItemProps) {
export function EducationItem({ className, headerLayout = "split", ...item }: EducationItemProps) {
const degreeAndGrade = [item.degree, item.grade].filter(Boolean).join(" • ");
const locationAndPeriod = [item.location, item.period].filter(Boolean).join(" • ");
const headerValues = {
@@ -48,21 +58,72 @@ export function EducationItem({ className, ...item }: EducationItemProps) {
},
);
const renderInlineHeader = () => {
// Leading cell keeps the header on a single line by only combining the
// two most essential fields: major (`area`) and degree. Grade and location
// are demoted to a secondary metadata line below the header so they don't
// force the header row to wrap on narrow grids.
const hasArea = Boolean(item.area?.trim());
const hasDegree = Boolean(item.degree?.trim());
const leading =
hasArea || hasDegree ? (
<span className="section-item-metadata education-item-area-degree">
{hasArea && <span className="education-item-area">{item.area}</span>}
{hasArea && hasDegree && " "}
{hasDegree && <span className="education-item-degree opacity-80">({item.degree})</span>}
</span>
) : null;
const middle = (
<LinkedTitle
title={item.school}
website={item.website}
showLinkInTitle={item.options?.showLinkInTitle}
className="section-item-title education-item-title"
/>
);
const trailing = item.period ? (
<span className="section-item-metadata education-item-period whitespace-nowrap">{item.period}</span>
) : null;
// Secondary line below the header: grade · location (when either exists).
// Mirrors the "degree · grade / location · period" pairing used by the
// default split layout, but fitted for a single-column template.
const gradeAndLocation = [item.grade, item.location].filter(Boolean).join(" • ");
return (
<>
<InlineHeader leading={leading} middle={middle} trailing={trailing} />
{gradeAndLocation && (
<div className="section-item-metadata education-item-grade-location mt-0.5 opacity-80">
{gradeAndLocation}
</div>
)}
</>
);
};
const renderSplitHeader = () => (
<div className="flex items-start justify-between gap-x-2">
<div className="flex min-w-0 flex-1 flex-col items-start">
{headerFields.get("school")?.content}
{headerFields.get("area")?.content}
</div>
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">
{headerFields.get("degreeAndGrade")?.content}
{headerFields.get("locationAndPeriod")?.content}
</div>
</div>
);
return (
<div className={cn("education-item", className)}>
{/* Header */}
<div className="section-item-header education-item-header mb-2">
<div className="flex items-start justify-between gap-x-2">
<div className="flex min-w-0 flex-1 flex-col items-start">
{headerFields.get("school")?.content}
{headerFields.get("area")?.content}
</div>
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">
{headerFields.get("degreeAndGrade")?.content}
{headerFields.get("locationAndPeriod")?.content}
</div>
</div>
{headerLayout === "inline" ? renderInlineHeader() : renderSplitHeader()}
</div>
{/* Description */}
@@ -5,14 +5,25 @@ import { filterFieldValues } from "@/utils/field";
import { stripHtml } from "@/utils/string";
import { cn } from "@/utils/style";
import { InlineHeader } from "../inline-header";
import { LinkedTitle } from "../linked-title";
import { PageLink } from "../page-link";
type ExperienceItemProps = SectionItem<"experience"> & {
className?: string;
/**
* Controls the header layout of each entry.
* - `"split"` (default): two-column layout — company/position on the start side,
* location/period on the end side. This preserves the behavior used by all
* existing templates.
* - `"inline"`: single-row, three-column layout — `[position (location)]`,
* `[company]`, `[period]`. Used by templates targeting compact single-line
* entry headers (e.g., Asian resume conventions).
*/
headerLayout?: "split" | "inline";
};
export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
export function ExperienceItem({ className, headerLayout = "split", ...item }: ExperienceItemProps) {
const hasRoles = Array.isArray(item.roles) && item.roles.length > 0;
const headerValues = {
company: item.company,
@@ -46,21 +57,57 @@ export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
content: <span className="section-item-metadata experience-item-period">{item.period}</span>,
},
);
const renderInlineHeader = () => {
// Combine position and location into a single leading cell so the header
// stays on one line, e.g. "Frontend Engineer (Guangzhou)".
const hasPosition = Boolean(item.position?.trim());
const hasLocation = Boolean(item.location?.trim());
const leading =
hasPosition || hasLocation ? (
<span className="section-item-metadata experience-item-position-location">
{hasPosition && <span className="experience-item-position">{item.position}</span>}
{hasPosition && hasLocation && " "}
{hasLocation && <span className="experience-item-location opacity-80">({item.location})</span>}
</span>
) : null;
const middle = (
<LinkedTitle
title={item.company}
website={item.website}
showLinkInTitle={item.options?.showLinkInTitle}
className="section-item-title experience-item-title"
/>
);
const trailing = item.period ? (
<span className="section-item-metadata experience-item-period whitespace-nowrap">{item.period}</span>
) : null;
return <InlineHeader leading={leading} middle={middle} trailing={trailing} />;
};
const renderSplitHeader = () => (
<div className="flex items-start justify-between gap-x-2">
<div className="flex min-w-0 flex-1 flex-col items-start">
{headerFields.get("company")?.content}
{headerFields.get("position")?.content}
</div>
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">
{headerFields.get("location")?.content}
{headerFields.get("period")?.content}
</div>
</div>
);
return (
<div className={cn("experience-item", className)}>
{/* Header */}
<div className="section-item-header experience-item-header">
<div className="flex items-start justify-between gap-x-2">
<div className="flex min-w-0 flex-1 flex-col items-start">
{headerFields.get("company")?.content}
{headerFields.get("position")?.content}
</div>
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">
{headerFields.get("location")?.content}
{headerFields.get("period")?.content}
</div>
</div>
{headerLayout === "inline" ? renderInlineHeader() : renderSplitHeader()}
</div>
{/* Role Progression */}
@@ -5,14 +5,27 @@ import { filterFieldValues } from "@/utils/field";
import { stripHtml } from "@/utils/string";
import { cn } from "@/utils/style";
import { InlineHeader } from "../inline-header";
import { LinkedTitle } from "../linked-title";
import { PageLink } from "../page-link";
type VolunteerItemProps = SectionItem<"volunteer"> & {
className?: string;
/**
* Controls the header layout of each entry.
* - `"split"` (default): two-column layout — organization/location on the
* start side, period on the end side.
* - `"inline"`: single-row, three-column layout — `[role (location)]`,
* `[organization]`, `[period]`. Used by templates targeting compact
* single-line entry headers (e.g., Asian resume conventions).
*
* Note: The volunteer schema doesn't have a dedicated "role" field; when
* inline layout is requested, the leading column falls back to the location.
*/
headerLayout?: "split" | "inline";
};
export function VolunteerItem({ className, ...item }: VolunteerItemProps) {
export function VolunteerItem({ className, headerLayout = "split", ...item }: VolunteerItemProps) {
const headerValues = {
organization: item.organization,
period: item.period,
@@ -41,18 +54,30 @@ export function VolunteerItem({ className, ...item }: VolunteerItemProps) {
},
);
const renderInlineHeader = () => (
<InlineHeader
leading={headerFields.get("location")?.content}
middle={headerFields.get("organization")?.content}
trailing={headerFields.get("period")?.content}
/>
);
const renderSplitHeader = () => (
<div className="flex items-start justify-between gap-x-2">
<div className="flex min-w-0 flex-1 flex-col items-start">
{headerFields.get("organization")?.content}
{headerFields.get("location")?.content}
</div>
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">{headerFields.get("period")?.content}</div>
</div>
);
return (
<div className={cn("volunteer-item", className)}>
{/* Header */}
<div className="section-item-header volunteer-item-header">
<div className="flex items-start justify-between gap-x-2">
<div className="flex min-w-0 flex-1 flex-col items-start">
{headerFields.get("organization")?.content}
{headerFields.get("location")?.content}
</div>
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">{headerFields.get("period")?.content}</div>
</div>
{headerLayout === "inline" ? renderInlineHeader() : renderSplitHeader()}
</div>
{/* Description */}
+110
View File
@@ -0,0 +1,110 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import type { TemplateProps } from "./types";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
// Section headings: uppercase, tracked, with a thin primary-colored underline.
// Kept ATS-friendly (plain text, no sidebar decoration).
const sectionClassName = cn(
"[&>h6]:mb-1 [&>h6]:border-b [&>h6]:border-(--page-primary-color) [&>h6]:pb-0.5",
"[&>h6]:tracking-wide [&>h6]:text-(--page-primary-color) [&>h6]:uppercase",
);
// Opt-in to the inline three-column header for experience / education / volunteer items.
const itemProps = { headerLayout: "inline" } as const;
/**
* Template: Meowth
*
* A single-column, ATS-friendly template with an inline three-column entry
* header (position/major · organization · period), optimized for Asian
* (Chinese / Japanese / Korean) resume conventions where these three
* fields traditionally share a single line per entry.
*/
export function MeowthTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-meowth page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName, itemProps });
return <Component key={section} id={section} />;
})}
</main>
{!fullWidth && (
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName, itemProps });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header flex items-start gap-x-4">
<div className="page-basics flex min-w-0 flex-1 flex-col gap-y-2">
<div>
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline opacity-80">{basics.headline}</p>
</div>
<div className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
</div>
))}
</div>
</div>
<PagePicture />
</div>
);
}
+7
View File
@@ -83,6 +83,13 @@ export const templates = {
tags: ["Two-column", "Muted sidebar", "Earthy", "Calm", "Sustainability", "Healthcare", "Nonprofit"],
sidebarPosition: "right",
},
meowth: {
name: "Meowth",
description: msg`Single-column with an inline three-column entry header (position · organization · period); compact and ATS-friendly, well-suited for Asian resume conventions (CN/JP/KR).`,
imageUrl: "/templates/jpg/meowth.jpg",
tags: ["Single-column", "ATS friendly", "Inline header", "Compact", "Asian style", "CN/JP/KR"],
sidebarPosition: "none",
},
onyx: {
name: "Onyx",
description: msg`Single-column with a sidebar and clean grid layout; versatile for any professional or technical role.`,
@@ -138,6 +138,7 @@ const CSS_SELECTORS = [
".template-kakuna",
".template-lapras",
".template-leafish",
".template-meowth",
".template-onyx",
".template-pikachu",
".template-rhyhorn",
+2
View File
@@ -11,6 +11,7 @@ export const templateSchema = z.enum([
"kakuna",
"lapras",
"leafish",
"meowth",
"onyx",
"pikachu",
"rhyhorn",
@@ -33,6 +34,7 @@ export const printMarginTemplates = [
"bronzor",
"kakuna",
"lapras",
"meowth",
"onyx",
"pikachu",
"rhyhorn",