feat: add semantic CSS stylesheets (#3274)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Amruth Pillai
2026-07-30 12:39:15 +02:00
committed by GitHub
co-authored by Cursor Agent
parent 4ac19f81b3
commit d2ffbf9618
320 changed files with 78393 additions and 2915 deletions
@@ -9,8 +9,10 @@ import type {
} from "../shared/types";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -23,7 +25,14 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles";
@@ -56,21 +65,34 @@ const azurillFeatures = {
sectionTimeline: true,
} satisfies TemplateFeatures;
export const AzurillPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const AzurillPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles, featureStyles } = useAzurillTemplate();
const metrics = getTemplateMetrics(metadata.page);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} featureStyles={featureStyles} colors={colors} features={azurillFeatures}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider
pageNodeKey={pageNodeKey}
styles={styles}
featureStyles={featureStyles}
colors={colors}
features={azurillFeatures}
>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.contentRow, { columnGap: metrics.columnGap })}>
<View
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sidebarColumn, {
flexBasis: `${metadata.layout.sidebarWidth}%`,
display: page.fullWidth ? "none" : "flex",
@@ -82,13 +104,13 @@ export const AzurillPage = ({ page, pageSize, pageMinHeightStyle, showHeader }:
<Section section={section} placement="sidebar" />
</Fragment>
))}
</View>
</SemanticRegionView>
<View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
</View>
</TemplateProvider>
</Page>
@@ -100,8 +122,8 @@ const Header = ({ styles }: AzurillHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -110,7 +132,7 @@ const Header = ({ styles }: AzurillHeaderProps) => {
</View>
</View>
<View style={styles.headerContactRow}>
<SemanticContactListView style={styles.headerContactRow}>
<EmailContactItem email={basics.email} style={styles.headerContactItem} />
<PhoneContactItem phone={basics.phone} style={styles.headerContactItem} />
<LocationContactItem location={basics.location} style={styles.headerContactItem} />
@@ -118,8 +140,8 @@ const Header = ({ styles }: AzurillHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.headerContactItem} />
))}
</View>
</View>
</SemanticContactListView>
</SemanticHeaderView>
);
};
@@ -0,0 +1,42 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const azurillSemanticManifest = {
template: "azurill",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [
{
name: "timeline-line",
key: "timeline-line",
owner: { kind: "section-items", key: "section-items", placement: "main", columns: 1 },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: "start" },
},
{
name: "timeline-marker",
key: "timeline-marker",
owner: { kind: "item", key: "item", placement: "main", columns: 1 },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: "start" },
},
{
name: "timeline-dot",
key: "timeline-dot",
owner: { kind: "item", key: "item", placement: "main", columns: 1 },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "timeline-marker", at: "start" },
},
{
name: "timeline-content",
key: "timeline-content",
owner: { kind: "item", key: "item", placement: "main", columns: 1 },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: "end", take: "all" },
},
],
} as const satisfies TemplateSemanticManifest;
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -17,7 +19,14 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight } from "../shared/styles";
@@ -68,25 +77,31 @@ const getBronzorSections = ({
return sections;
};
export const BronzorPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const BronzorPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { styles, colors } = useBronzorTemplate();
const metrics = getTemplateMetrics(metadata.page);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const sections = getBronzorSections({ mainSections, sidebarSections, fullWidth: page.fullWidth });
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sections, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.sections, { rowGap: metrics.sectionGap })}>
{sections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
</TemplateProvider>
</Page>
);
@@ -97,8 +112,8 @@ const Header = ({ styles }: BronzorHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -107,7 +122,7 @@ const Header = ({ styles }: BronzorHeaderProps) => {
</View>
</View>
<View style={styles.headerContactRow}>
<SemanticContactListView style={styles.headerContactRow}>
<EmailContactItem email={basics.email} style={styles.headerContactItem} />
<PhoneContactItem phone={basics.phone} style={styles.headerContactItem} />
<LocationContactItem location={basics.location} style={styles.headerContactItem} />
@@ -115,8 +130,8 @@ const Header = ({ styles }: BronzorHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.headerContactItem} />
))}
</View>
</View>
</SemanticContactListView>
</SemanticHeaderView>
);
};
@@ -0,0 +1,23 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const bronzorSemanticManifest = {
template: "bronzor",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["sidebar", "main"], flow: "interleaved" },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [
{
name: "interleaved-section-row",
key: "interleaved-section-row",
owner: { kind: "section", key: "section", placement: "main" },
binding: {
type: "alias",
canonicalKind: "section",
token: "interleaved-section-row",
},
},
],
} as const satisfies TemplateSemanticManifest;
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -17,7 +19,15 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticContactRowView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles";
@@ -45,19 +55,26 @@ type ChikoritaHeaderProps = {
styles: ChikoritaStyles;
};
export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata, picture } = data;
const { colors, styles } = useChikoritaTemplate();
const metrics = getTemplateMetrics(metadata.page);
const hasPicture = hasTemplatePicture(picture);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<View
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
<SemanticRegionView
region="main"
style={composeStyles(styles.mainColumn, {
paddingTop: metrics.page.paddingVertical,
paddingRight: page.fullWidth ? metrics.page.paddingHorizontal : metrics.columnGap,
@@ -71,9 +88,10 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader }
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
<View
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sidebarColumn, {
display: page.fullWidth ? "none" : "flex",
flexBasis: `${metadata.layout.sidebarWidth}%`,
@@ -92,7 +110,7 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader }
<Section section={section} placement="sidebar" />
</Fragment>
))}
</View>
</SemanticRegionView>
</TemplateProvider>
</Page>
);
@@ -103,8 +121,8 @@ const Header = ({ styles }: ChikoritaHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -112,22 +130,22 @@ const Header = ({ styles }: ChikoritaHeaderProps) => {
<Text>{basics.headline}</Text>
</View>
<View style={styles.headerContactList}>
<View style={styles.headerContactRow}>
<SemanticContactListView style={styles.headerContactList}>
<SemanticContactRowView partKey="contact-row-primary" style={styles.headerContactRow}>
<EmailContactItem email={basics.email} style={styles.headerContactItem} />
<PhoneContactItem phone={basics.phone} style={styles.headerContactItem} />
<LocationContactItem location={basics.location} style={styles.headerContactItem} />
</View>
</SemanticContactRowView>
<View style={styles.headerContactRow}>
<SemanticContactRowView partKey="contact-row-secondary" style={styles.headerContactRow}>
<WebsiteContactItem website={basics.website} style={styles.headerContactItem} />
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.headerContactItem} />
))}
</View>
</View>
</SemanticContactRowView>
</SemanticContactListView>
</View>
</View>
</SemanticHeaderView>
);
};
@@ -0,0 +1,43 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const chikoritaSemanticManifest = {
template: "chikorita",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [
{
name: "contact-row-primary",
key: "contact-row-primary",
owner: { kind: "contact-list", key: "contact-list" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: {
parent: "owner",
at: "start",
take: [
{ kind: "contact-item", name: "email" },
{ kind: "contact-item", name: "phone" },
{ kind: "contact-item", name: "location" },
],
},
},
{
name: "contact-row-secondary",
key: "contact-row-secondary",
owner: { kind: "contact-list", key: "contact-list" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: {
parent: "owner",
at: "end",
take: [
{ kind: "contact-item", name: "website" },
{ kind: "contact-item", name: "custom" },
],
},
},
],
} as const satisfies TemplateSemanticManifest;
@@ -35,13 +35,15 @@ describe("cover letter PDF layout", () => {
expect(source).toContain("showHeader={shouldShowResumeHeader");
expect(source).toContain("pageSize={pageSize}");
expect(source).toContain("pageMinHeightStyle={pageMinHeightStyle}");
expect(source).toContain("pageNumber={index + 1}");
});
it.each(pageFiles)("%s renders the shared page props", (file) => {
const source = readTemplate(file);
expect(source, basename(file)).toContain("pageSize, pageMinHeightStyle, showHeader");
expect(source, basename(file)).toContain("<Page size={pageSize}");
expect(source, basename(file)).toContain("pageSize, pageMinHeightStyle, showHeader, pageNumber");
expect(source, basename(file)).toContain("size={semanticPageSize ?? pageSize}");
expect(source, basename(file)).toContain("pageNodeKey={pageNodeKey}");
expect(source, basename(file)).toContain("showHeader &&");
expect(source, basename(file)).not.toContain('from "../shared/cover-letter"');
expect(source, basename(file)).not.toContain('from "../shared/page-size"');
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import { getPrimaryTint } from "../shared/color-helpers";
import {
@@ -19,7 +21,15 @@ import { getFeaturedSummaryLayout } from "../shared/featured-summary";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionTemplatePartView,
SemanticRegionView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles";
@@ -56,14 +66,16 @@ const ditgarFeatures = {
mainItemHeaderBorder: true,
} satisfies TemplateFeatures;
export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useDitgarTemplate();
const metrics = getTemplateMetrics(metadata.page);
const showSidebar = !page.fullWidth || showHeader;
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const { featuredSummarySection, regularSections: regularMainSections } = getFeaturedSummaryLayout({
sections: mainSections,
canFeatureSummary: showHeader,
@@ -73,8 +85,12 @@ export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: T
: sidebarSections;
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors} features={ditgarFeatures}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors} features={ditgarFeatures}>
{showSidebar && (
<View
style={composeStyles(styles.sidebarColumn, {
@@ -84,27 +100,34 @@ export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: T
{showHeader && <Header styles={styles} colors={colors} />}
{!page.fullWidth && (
<View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}>
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}
>
{regularSidebarSections.map((section) => (
<Section key={section} section={section} placement="sidebar" />
))}
</View>
</SemanticRegionView>
)}
</View>
)}
<View style={styles.mainColumn}>
{featuredSummarySection && (
<View style={styles.specialContainer}>
<SemanticRegionTemplatePartView
region="featured"
partKeys={["featured-summary"]}
style={styles.specialContainer}
>
<Section section={featuredSummarySection} placement="main" showHeading={false} />
</View>
</SemanticRegionTemplatePartView>
)}
<View style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
{regularMainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
</View>
</TemplateProvider>
</Page>
@@ -116,8 +139,8 @@ const Header = ({ styles, colors }: DitgarHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -126,7 +149,7 @@ const Header = ({ styles, colors }: DitgarHeaderProps) => {
</View>
</View>
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem
email={basics.email}
style={styles.contactItem}
@@ -161,8 +184,8 @@ const Header = ({ styles, colors }: DitgarHeaderProps) => {
iconColor={colors.background}
/>
))}
</View>
</View>
</SemanticContactListView>
</SemanticHeaderView>
);
};
@@ -0,0 +1,53 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
const borderedItemHeaderSectionTypes = [
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
] as const;
export const ditgarSemanticManifest = {
template: "ditgar",
regions: [
{ name: "header", placement: "sidebar", origins: [] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
{ name: "featured", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
],
header: { region: "header", placement: "sidebar" },
specialSummary: { region: "featured", placement: "main", source: "main-with-header" },
parts: [
{
name: "featured-summary",
key: "featured-summary",
owner: { kind: "region", key: "featured" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: "start", take: "all" },
},
{
name: "sidebar-background",
key: "sidebar-background",
owner: { kind: "region", key: "sidebar" },
binding: { type: "alias", canonicalKind: "region", token: "sidebar-background" },
},
{
name: "item-header-border",
key: "item-header-border",
owner: {
kind: "item-header",
key: "item-header",
sectionTypes: borderedItemHeaderSectionTypes,
},
binding: { type: "alias", canonicalKind: "item-header", token: "item-header-border" },
},
],
} as const satisfies TemplateSemanticManifest;
+39 -19
View File
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -17,7 +19,15 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
SemanticTemplatePartView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight } from "../shared/styles";
@@ -50,22 +60,29 @@ type DittoHeaderProps = {
styles: DittoStyles;
};
export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata, picture } = data;
const { colors, styles } = useDittoTemplate();
const metrics = getTemplateMetrics(metadata.page);
const hasPicture = hasTemplatePicture(picture);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.contentRow, { paddingTop: metrics.headerGap })}>
<View
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sidebarColumn, {
display: page.fullWidth ? "none" : "flex",
width: `${metadata.layout.sidebarWidth}%`,
@@ -79,9 +96,10 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: Te
<Section section={section} placement="sidebar" />
</Fragment>
))}
</View>
</SemanticRegionView>
<View
<SemanticRegionView
region="main"
style={composeStyles(styles.mainColumn, {
paddingLeft: metrics.columnGap,
paddingRight: metrics.page.paddingHorizontal,
@@ -91,7 +109,7 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: Te
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
</View>
</TemplateProvider>
</Page>
@@ -103,9 +121,11 @@ const Header = ({ styles }: DittoHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
<View style={styles.headerBand}>
<View style={styles.pictureAnchor}>{hasPicture && <Image src={picture.url} style={styles.picture} />}</View>
<SemanticHeaderView style={styles.header}>
<SemanticTemplatePartView partKeys={["header-band"]} style={styles.headerBand}>
<SemanticTemplatePartView partKeys={["header-band", "picture-anchor"]} style={styles.pictureAnchor}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
</SemanticTemplatePartView>
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -113,12 +133,12 @@ const Header = ({ styles }: DittoHeaderProps) => {
<Text style={styles.headerHeadline}>{basics.headline}</Text>
</View>
</View>
</View>
</SemanticTemplatePartView>
<View style={styles.contactRow}>
<View style={styles.contactOffset} />
<SemanticTemplatePartView partKeys={["contact-offset"]} style={styles.contactOffset} />
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
@@ -126,9 +146,9 @@ const Header = ({ styles }: DittoHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</View>
</SemanticContactListView>
</View>
</View>
</SemanticHeaderView>
);
};
@@ -0,0 +1,39 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const dittoSemanticManifest = {
template: "ditto",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [
{
name: "header-band",
key: "header-band",
owner: { kind: "header", key: "header" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: {
parent: "owner",
at: "start",
take: [{ kind: "picture" }, { kind: "name" }, { kind: "headline" }],
},
},
{
name: "picture-anchor",
key: "picture-anchor",
owner: { kind: "header", key: "header" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "header-band", at: "start", take: [{ kind: "picture" }] },
},
{
name: "contact-offset",
key: "contact-offset",
owner: { kind: "header", key: "header" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: { before: { kind: "contact-list" } } },
},
],
} as const satisfies TemplateSemanticManifest;
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import { getPrimaryTint } from "../shared/color-helpers";
import {
@@ -19,7 +21,15 @@ import { getFeaturedSummaryLayout } from "../shared/featured-summary";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionTemplatePartView,
SemanticRegionView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles";
@@ -51,14 +61,16 @@ type GengarHeaderProps = {
colors: TemplateColorRoles;
};
export const GengarPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const GengarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useGengarTemplate();
const metrics = getTemplateMetrics(metadata.page);
const showSidebar = !page.fullWidth || showHeader;
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const { featuredSummarySection, regularSections: regularMainSections } = getFeaturedSummaryLayout({
sections: mainSections,
canFeatureSummary: showHeader,
@@ -68,8 +80,12 @@ export const GengarPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: T
: sidebarSections;
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showSidebar && (
<View
style={composeStyles(styles.sidebarColumn, {
@@ -79,29 +95,33 @@ export const GengarPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: T
{showHeader && <Header styles={styles} colors={colors} />}
{!page.fullWidth && (
<View style={styles.sidebarContent}>
<SemanticRegionView region="sidebar" style={styles.sidebarContent}>
{regularSidebarSections.map((section) => (
<Fragment key={section}>
<Section section={section} placement="sidebar" />
</Fragment>
))}
</View>
</SemanticRegionView>
)}
</View>
)}
<View style={styles.mainColumn}>
{featuredSummarySection && (
<View style={styles.specialContainer}>
<SemanticRegionTemplatePartView
region="featured"
partKeys={["featured-summary"]}
style={styles.specialContainer}
>
<Section section={featuredSummarySection} placement="main" showHeading={false} />
</View>
</SemanticRegionTemplatePartView>
)}
<View style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
{regularMainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
</View>
</TemplateProvider>
</Page>
@@ -113,8 +133,8 @@ const Header = ({ styles, colors }: GengarHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -123,7 +143,7 @@ const Header = ({ styles, colors }: GengarHeaderProps) => {
</View>
</View>
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem
email={basics.email}
style={styles.contactItem}
@@ -157,8 +177,8 @@ const Header = ({ styles, colors }: GengarHeaderProps) => {
iconColor={colors.background}
/>
))}
</View>
</View>
</SemanticContactListView>
</SemanticHeaderView>
);
};
@@ -0,0 +1,28 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const gengarSemanticManifest = {
template: "gengar",
regions: [
{ name: "header", placement: "sidebar", origins: [] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
{ name: "featured", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
],
header: { region: "header", placement: "sidebar" },
specialSummary: { region: "featured", placement: "main", source: "main-with-header" },
parts: [
{
name: "featured-summary",
key: "featured-summary",
owner: { kind: "region", key: "featured" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: "start", take: "all" },
},
{
name: "sidebar-background",
key: "sidebar-background",
owner: { kind: "region", key: "sidebar" },
binding: { type: "alias", canonicalKind: "region", token: "sidebar-background" },
},
],
} as const satisfies TemplateSemanticManifest;
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import { getPrimaryTint } from "../shared/color-helpers";
import {
@@ -18,7 +20,15 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
SemanticTemplatePartView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles";
@@ -53,19 +63,31 @@ const glalieFeatures = {
stackSidebarItemHeader: true,
} satisfies TemplateFeatures;
export const GlaliePage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const GlaliePage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useGlalieTemplate();
const metrics = getTemplateMetrics(metadata.page);
const showSidebar = !page.fullWidth || showHeader;
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors} features={glalieFeatures}>
{showSidebar && <View style={styles.sidebarBackground} />}
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors} features={glalieFeatures}>
{showSidebar && (
<SemanticTemplatePartView
ownerNodeKey={semanticNodeKeys.region(pageNodeKey, "sidebar")}
partKeys={["sidebar-background"]}
style={styles.sidebarBackground}
/>
)}
<View style={styles.layout}>
{showSidebar && (
@@ -77,21 +99,24 @@ export const GlaliePage = ({ page, pageSize, pageMinHeightStyle, showHeader }: T
{showHeader && <Header styles={styles} />}
{!page.fullWidth && (
<View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}>
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}
>
{sidebarSections.map((section) => (
<Section key={section} section={section} placement="sidebar" />
))}
</View>
</SemanticRegionView>
)}
</View>
)}
<View style={styles.mainColumn}>
<View style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
</View>
</View>
</TemplateProvider>
@@ -104,8 +129,8 @@ const Header = ({ styles }: GlalieHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -114,7 +139,7 @@ const Header = ({ styles }: GlalieHeaderProps) => {
</View>
</View>
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
@@ -122,8 +147,8 @@ const Header = ({ styles }: GlalieHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</View>
</View>
</SemanticContactListView>
</SemanticHeaderView>
);
};
@@ -0,0 +1,21 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const glalieSemanticManifest = {
template: "glalie",
regions: [
{ name: "header", placement: "sidebar", origins: [] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
{ name: "main", placement: "main", origins: ["main"] },
],
header: { region: "header", placement: "sidebar" },
specialSummary: null,
parts: [
{
name: "sidebar-background",
key: "sidebar-background",
owner: { kind: "region", key: "sidebar" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: "start" },
},
],
} as const satisfies TemplateSemanticManifest;
+8 -2
View File
@@ -2,11 +2,17 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const registry = readFileSync(fileURLToPath(new URL("./index.ts", import.meta.url)), "utf8");
describe("templatePages", () => {
it("registers Scizor as a renderable template page", () => {
const registry = readFileSync(fileURLToPath(new URL("./index.ts", import.meta.url)), "utf8");
expect(registry).toContain('import { ScizorPage } from "./scizor/ScizorPage";');
expect(registry).toContain("scizor: ScizorPage");
});
it("exports the semantic manifest registry through the template index", () => {
expect(registry).toContain("getTemplateSemanticManifest");
expect(registry).toContain("getTemplateSemanticRegistryFingerprintInput");
expect(registry).toContain("TemplateSemanticManifest");
});
});
+8
View File
@@ -37,3 +37,11 @@ export const templatePages: Partial<Record<Template, TemplatePage>> = {
const defaultTemplatePage = AzurillPage;
export const getTemplatePage = (template: Template): TemplatePage => templatePages[template] ?? defaultTemplatePage;
export type { TemplateSemanticManifest } from "../semantic/template-manifest";
export {
getTemplateSemanticBindingRegistry,
getTemplateSemanticManifest,
getTemplateSemanticRegistryFingerprintInput,
validateTemplateSemanticManifest,
} from "../semantic/template-manifest";
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -17,7 +19,14 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight } from "../shared/styles";
@@ -44,31 +53,40 @@ type KakunaHeaderProps = {
styles: KakunaStyles;
};
export const KakunaPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const KakunaPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useKakunaTemplate();
const metrics = getTemplateMetrics(metadata.page);
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
{!page.fullWidth && (
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}
>
{sidebarSections.map((section) => (
<Section key={section} section={section} placement="sidebar" />
))}
</View>
</SemanticRegionView>
)}
</TemplateProvider>
</Page>
@@ -80,8 +98,8 @@ const Header = ({ styles }: KakunaHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerCopy}>
@@ -89,7 +107,7 @@ const Header = ({ styles }: KakunaHeaderProps) => {
<Text style={styles.headerText}>{basics.headline}</Text>
</View>
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
@@ -97,9 +115,9 @@ const Header = ({ styles }: KakunaHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</View>
</SemanticContactListView>
</View>
</View>
</SemanticHeaderView>
);
};
@@ -0,0 +1,13 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const kakunaSemanticManifest = {
template: "kakuna",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [],
} as const satisfies TemplateSemanticManifest;
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -17,7 +19,14 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight } from "../shared/styles";
@@ -43,31 +52,40 @@ type LaprasHeaderProps = {
styles: LaprasStyles;
};
export const LaprasPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const LaprasPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useLaprasTemplate();
const metrics = getTemplateMetrics(metadata.page);
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.gapY(1.5) })}>
<SemanticRegionView region="main" style={composeStyles(styles.sectionGroup, { rowGap: metrics.gapY(1.5) })}>
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
{!page.fullWidth && (
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.gapY(1.5) })}>
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sectionGroup, { rowGap: metrics.gapY(1.5) })}
>
{sidebarSections.map((section) => (
<Section key={section} section={section} placement="sidebar" />
))}
</View>
</SemanticRegionView>
)}
</TemplateProvider>
</Page>
@@ -79,8 +97,8 @@ const Header = ({ styles }: LaprasHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -88,7 +106,7 @@ const Header = ({ styles }: LaprasHeaderProps) => {
<Text>{basics.headline}</Text>
</View>
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
@@ -96,9 +114,9 @@ const Header = ({ styles }: LaprasHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</View>
</SemanticContactListView>
</View>
</View>
</SemanticHeaderView>
);
};
@@ -0,0 +1,13 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const laprasSemanticManifest = {
template: "lapras",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [],
} as const satisfies TemplateSemanticManifest;
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import { getPrimaryTint as getPrimaryAlpha } from "../shared/color-helpers";
import {
@@ -18,7 +20,15 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
SemanticTemplatePartView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight } from "../shared/styles";
@@ -49,28 +59,41 @@ type LeafishHeaderProps = {
styles: LeafishStyles;
};
export const LeafishPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const LeafishPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useLeafishTemplate();
const metrics = getTemplateMetrics(metadata.page);
const mainSections = filterSections(page.main, data).filter((section) => section !== "summary");
const sidebarSections = filterSections(page.sidebar, data).filter((section) => section !== "summary");
const mainSections = useRenderedSectionIds(
pageNodeKey,
filterSections(page.main, data).filter((section) => section !== "summary"),
);
const sidebarSections = useRenderedSectionIds(
pageNodeKey,
filterSections(page.sidebar, data).filter((section) => section !== "summary"),
);
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={styles.body}>
<View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
{!page.fullWidth && (
<View
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sidebarColumn, {
width: `${metadata.layout.sidebarWidth}%`,
rowGap: metrics.sectionGap,
@@ -79,7 +102,7 @@ export const LeafishPage = ({ page, pageSize, pageMinHeightStyle, showHeader }:
{sidebarSections.map((section) => (
<Section key={section} section={section} placement="sidebar" />
))}
</View>
</SemanticRegionView>
)}
</View>
</TemplateProvider>
@@ -92,10 +115,10 @@ const Header = ({ styles }: LeafishHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
<View style={styles.headerIntro}>
<View style={styles.headerBody}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
<SemanticTemplatePartView partKeys={["header-intro"]} style={styles.headerIntro}>
<SemanticTemplatePartView partKeys={["header-intro", "header-body"]} style={styles.headerBody}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -105,11 +128,11 @@ const Header = ({ styles }: LeafishHeaderProps) => {
<Section section="summary" placement="main" showHeading={false} />
</View>
</View>
</View>
</SemanticTemplatePartView>
</SemanticTemplatePartView>
<View style={styles.headerContactBand}>
<View style={styles.contactList}>
<SemanticTemplatePartView partKeys={["header-contact-band"]} style={styles.headerContactBand}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
@@ -117,9 +140,9 @@ const Header = ({ styles }: LeafishHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</View>
</View>
</View>
</SemanticContactListView>
</SemanticTemplatePartView>
</SemanticHeaderView>
);
};
@@ -0,0 +1,39 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const leafishSemanticManifest = {
template: "leafish",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: { region: "header", placement: "main", source: "always" },
parts: [
{
name: "header-intro",
key: "header-intro",
owner: { kind: "header", key: "header" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: {
parent: "owner",
at: "start",
take: [{ kind: "picture" }, { kind: "name" }, { kind: "headline" }, { kind: "section", name: "summary" }],
},
},
{
name: "header-body",
key: "header-body",
owner: { kind: "header", key: "header" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "header-intro", at: "start", take: "all" },
},
{
name: "header-contact-band",
key: "header-contact-band",
owner: { kind: "header", key: "header" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: "end", take: [{ kind: "contact-list" }] },
},
],
} as const satisfies TemplateSemanticManifest;
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -17,7 +19,14 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight } from "../shared/styles";
@@ -48,31 +57,40 @@ const meowthFeatures = {
inlineItemHeader: true,
} satisfies TemplateFeatures;
export const MeowthPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const MeowthPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useMeowthTemplate();
const metrics = getTemplateMetrics(metadata.page);
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors} features={meowthFeatures}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors} features={meowthFeatures}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
{!page.fullWidth && (
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}
>
{sidebarSections.map((section) => (
<Section key={section} section={section} placement="sidebar" />
))}
</View>
</SemanticRegionView>
)}
</TemplateProvider>
</Page>
@@ -84,14 +102,14 @@ const Header = ({ styles }: MeowthHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
<SemanticHeaderView style={styles.header}>
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text style={styles.headerHeadline}>{basics.headline}</Text>
</View>
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
@@ -99,11 +117,11 @@ const Header = ({ styles }: MeowthHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</View>
</SemanticContactListView>
</View>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
</View>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
</SemanticHeaderView>
);
};
@@ -0,0 +1,70 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
const inlineHeaderSectionTypes = ["experience", "education", "volunteer"] as const;
export const meowthSemanticManifest = {
template: "meowth",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [
{
name: "inline-item-header-leading",
key: "inline-item-header-leading",
owner: { kind: "item-header", key: "item-header", sectionTypes: inlineHeaderSectionTypes },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: {
parent: "owner",
at: "start",
take: [
{ kind: "field", name: "position", sectionTypes: ["experience", "volunteer"] },
{ kind: "field", name: "location", sectionTypes: ["experience", "volunteer"] },
{ kind: "field", name: "area", sectionTypes: ["education"] },
{ kind: "field", name: "degree", sectionTypes: ["education"] },
],
},
},
{
name: "inline-item-header-middle",
key: "inline-item-header-middle",
owner: { kind: "item-header", key: "item-header", sectionTypes: inlineHeaderSectionTypes },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: {
parent: "owner",
at: "start",
take: [
{ kind: "field", name: "company" },
{ kind: "field", name: "school" },
{ kind: "field", name: "organization" },
{ kind: "link" },
],
},
},
{
name: "inline-item-header-trailing",
key: "inline-item-header-trailing",
owner: { kind: "item-header", key: "item-header", sectionTypes: inlineHeaderSectionTypes },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: "start", take: [{ kind: "field", name: "period" }] },
},
{
name: "education-grade-row",
key: "education-grade-row",
owner: { kind: "item", key: "item" },
binding: { type: "primitive", primitive: "Text", source: "existing" },
route: {
parent: "owner",
at: { after: { kind: "item-header" } },
take: [
{ kind: "field", name: "grade", sectionTypes: ["education"] },
{ kind: "field", name: "location", sectionTypes: ["education"] },
],
takeFrom: "item-header",
},
},
],
} as const satisfies TemplateSemanticManifest;
+34 -16
View File
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -17,7 +19,14 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight } from "../shared/styles";
@@ -43,31 +52,40 @@ type OnyxHeaderProps = {
styles: OnyxStyles;
};
export const OnyxPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const OnyxPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useOnyxTemplate();
const metrics = getTemplateMetrics(metadata.page);
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
{!page.fullWidth && (
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}
>
{sidebarSections.map((section) => (
<Section key={section} section={section} placement="sidebar" />
))}
</View>
</SemanticRegionView>
)}
</TemplateProvider>
</Page>
@@ -79,8 +97,8 @@ const Header = ({ styles }: OnyxHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
@@ -88,7 +106,7 @@ const Header = ({ styles }: OnyxHeaderProps) => {
<Text>{basics.headline}</Text>
</View>
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
@@ -96,9 +114,9 @@ const Header = ({ styles }: OnyxHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</View>
</SemanticContactListView>
</View>
</View>
</SemanticHeaderView>
);
};
@@ -0,0 +1,13 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const onyxSemanticManifest = {
template: "onyx",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [],
} as const satisfies TemplateSemanticManifest;
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -17,7 +19,15 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
SemanticTemplatePartView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles";
@@ -53,19 +63,25 @@ const pikachuFeatures = {
stackSidebarItemHeader: true,
} satisfies TemplateFeatures;
export const PikachuPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const PikachuPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata, picture } = data;
const { colors, styles } = usePikachuTemplate();
const metrics = getTemplateMetrics(metadata.page);
const showSidebar = !page.fullWidth;
const hasPicture = hasTemplatePicture(picture);
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors} features={pikachuFeatures}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors} features={pikachuFeatures}>
<View style={styles.layout}>
{showSidebar && (
<View
@@ -74,20 +90,27 @@ export const PikachuPage = ({ page, pageSize, pageMinHeightStyle, showHeader }:
rowGap: metrics.sectionGap,
})}
>
{showHeader && showSidebar && hasPicture && <Image src={picture.url} style={styles.picture} />}
{showHeader && showSidebar && hasPicture && (
<SemanticHeaderPicture src={picture.url} style={styles.picture} />
)}
<View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}>
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}
>
{sidebarSections.map((section) => (
<Section key={section} section={section} placement="sidebar" />
))}
</View>
</SemanticRegionView>
</View>
)}
<View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
{showHeader && (
<View style={styles.headerRow}>
{showHeader && !showSidebar && hasPicture && <Image src={picture.url} style={styles.picture} />}
{showHeader && !showSidebar && hasPicture && (
<SemanticHeaderPicture src={picture.url} style={styles.picture} />
)}
<Header styles={styles} colors={colors} />
</View>
)}
@@ -97,7 +120,7 @@ export const PikachuPage = ({ page, pageSize, pageMinHeightStyle, showHeader }:
<Section key={section} section={section} placement="main" />
))}
</View>
</View>
</SemanticRegionView>
</View>
</TemplateProvider>
</Page>
@@ -108,15 +131,15 @@ const Header = ({ styles, colors }: PikachuHeaderProps) => {
const { basics } = useRender();
return (
<View style={styles.header}>
<View style={styles.headerDivider}>
<SemanticHeaderView style={styles.header}>
<SemanticTemplatePartView partKeys={["header-divider"]} style={styles.headerDivider}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text style={styles.headerText}>{basics.headline}</Text>
</View>
</View>
</SemanticTemplatePartView>
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem
email={basics.email}
style={styles.contactItem}
@@ -150,8 +173,8 @@ const Header = ({ styles, colors }: PikachuHeaderProps) => {
iconColor={colors.background}
/>
))}
</View>
</View>
</SemanticContactListView>
</SemanticHeaderView>
);
};
@@ -0,0 +1,21 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const pikachuSemanticManifest = {
template: "pikachu",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [
{
name: "header-divider",
key: "header-divider",
owner: { kind: "header", key: "header" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: "start", take: [{ kind: "name" }, { kind: "headline" }] },
},
],
} as const satisfies TemplateSemanticManifest;
@@ -4,8 +4,11 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { resolvedPdfFlowProps } from "../../semantic/adapter";
import { useRenderedSectionIds, useResolvedNode, useSemanticNodeVisible } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -14,11 +17,19 @@ import {
PhoneContactItem,
WebsiteContactItem,
} from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { TemplateProvider, useTemplatePageNodeKey } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
semanticTemplatePartNodeKey,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight } from "../shared/styles";
@@ -46,31 +57,62 @@ type RhyhornHeaderProps = {
styles: RhyhornStyles;
};
export const RhyhornPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
type RhyhornContactItemProps = {
nodeKey: string | undefined;
last: boolean;
styles: RhyhornStyles;
children: ReactNode;
};
const RhyhornContactItem = ({ nodeKey, last, styles, children }: RhyhornContactItemProps) => {
const resolved = useResolvedNode(nodeKey);
const visible = useSemanticNodeVisible(nodeKey);
if (!visible) return null;
return (
<View
{...resolvedPdfFlowProps(resolved)}
style={composeStyles(styles.contactItem, last ? styles.contactItemLast : undefined, resolved.style)}
>
{children}
</View>
);
};
export const RhyhornPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useRhyhornTemplate();
const metrics = getTemplateMetrics(metadata.page);
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
{!page.fullWidth && (
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}
>
{sidebarSections.map((section) => (
<Section key={section} section={section} placement="sidebar" />
))}
</View>
</SemanticRegionView>
)}
</TemplateProvider>
</Page>
@@ -80,69 +122,111 @@ export const RhyhornPage = ({ page, pageSize, pageMinHeightStyle, showHeader }:
const Header = ({ styles }: RhyhornHeaderProps) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
const pageNodeKey = useTemplatePageNodeKey();
const headerNodeKey = semanticNodeKeys.header(semanticNodeKeys.region(pageNodeKey, "header"));
const contactListNodeKey = headerNodeKey ? semanticNodeKeys.contactList(headerNodeKey) : undefined;
const contactNodeKey = (name: string, id?: string) =>
contactListNodeKey ? semanticNodeKeys.contactItem(contactListNodeKey, name, id) : undefined;
const contentNodeKey = (name: string, id?: string) =>
semanticTemplatePartNodeKey(contactNodeKey(name, id), "contact-item-content");
const contactItems: {
id: string;
nodeKey: string | undefined;
content: ReactNode;
}[] = [];
if (basics.email) {
contactItems.push({
id: "email",
content: <EmailContactItem email={basics.email} style={styles.contactItemContent} />,
nodeKey: contactNodeKey("email"),
content: (
<EmailContactItem
email={basics.email}
primitiveNodeKey={contentNodeKey("email")}
style={styles.contactItemContent}
/>
),
});
}
if (basics.phone) {
contactItems.push({
id: "phone",
content: <PhoneContactItem phone={basics.phone} style={styles.contactItemContent} />,
nodeKey: contactNodeKey("phone"),
content: (
<PhoneContactItem
phone={basics.phone}
primitiveNodeKey={contentNodeKey("phone")}
style={styles.contactItemContent}
/>
),
});
}
if (basics.location) {
contactItems.push({
id: "location",
content: <LocationContactItem location={basics.location} style={styles.contactItemContent} />,
nodeKey: contactNodeKey("location"),
content: (
<LocationContactItem
location={basics.location}
primitiveNodeKey={contentNodeKey("location")}
style={styles.contactItemContent}
/>
),
});
}
if (basics.website.url) {
contactItems.push({
id: "website",
content: <WebsiteContactItem website={basics.website} style={styles.contactItemContent} />,
nodeKey: contactNodeKey("website"),
content: (
<WebsiteContactItem
website={basics.website}
primitiveNodeKey={contentNodeKey("website")}
style={styles.contactItemContent}
/>
),
});
}
contactItems.push(
...basics.customFields.map((field) => ({
id: `custom-${field.id}`,
content: <CustomFieldContactItem field={field} style={styles.contactItemContent} />,
nodeKey: contactNodeKey("custom", field.id),
content: (
<CustomFieldContactItem
field={field}
primitiveNodeKey={contentNodeKey("custom", field.id)}
style={styles.contactItemContent}
/>
),
})),
);
return (
<View style={styles.header}>
<SemanticHeaderView style={styles.header}>
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text>{basics.headline}</Text>
</View>
<View style={styles.contactList}>
<SemanticContactListView style={styles.contactList}>
{contactItems.map((item, index) => (
<View
<RhyhornContactItem
key={item.id}
style={composeStyles(
styles.contactItem,
index === contactItems.length - 1 ? styles.contactItemLast : undefined,
)}
nodeKey={item.nodeKey}
last={index === contactItems.length - 1}
styles={styles}
>
{item.content}
</View>
</RhyhornContactItem>
))}
</View>
</SemanticContactListView>
</View>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
</View>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
</SemanticHeaderView>
);
};
@@ -0,0 +1,32 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const rhyhornSemanticManifest = {
template: "rhyhorn",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main"] },
{ name: "sidebar", placement: "sidebar", origins: ["sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [
{
name: "contact-item-content",
key: "contact-item-content",
owner: { kind: "contact-item", key: "contact-item" },
binding: {
type: "primitive",
primitive: { ownerRole: "structured-link", present: "Link", absent: "View" },
source: "existing",
},
route: { parent: "owner", at: "start", take: "all" },
},
{
name: "contact-item-last",
key: "contact-item-last",
owner: { kind: "contact-item", key: "contact-item", position: "last" },
binding: { type: "alias", canonicalKind: "contact-item", token: "contact-item-last" },
},
],
canonicalBindings: [{ kind: "contact-item", binding: { type: "primitive", primitive: "View", source: "existing" } }],
} as const satisfies TemplateSemanticManifest;
@@ -3,8 +3,10 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { createBaseTemplateStyles } from "../shared/base-template-styles";
import {
CustomFieldContactItem,
@@ -17,7 +19,15 @@ import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Text } from "../shared/primitives";
import {
Heading,
SemanticContactListView,
SemanticHeaderPicture,
SemanticHeaderView,
SemanticRegionView,
SemanticTemplatePartView,
Text,
} from "../shared/primitives";
import { createRtlStyleHelpers } from "../shared/rtl";
import { Section } from "../shared/sections";
import { composeStyles, headerNameLineHeight } from "../shared/styles";
@@ -44,25 +54,31 @@ type ScizorHeaderProps = {
styles: ScizorStyles;
};
export const ScizorPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
export const ScizorPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
const data = useRender();
const pageNodeKey = semanticNodeKeys.page(pageNumber);
const { style: semanticPageStyle, size: semanticPageSize, ...semanticPageProps } = useResolvedNode(pageNodeKey);
const { metadata } = data;
const { colors, styles } = useScizorTemplate();
const metrics = getTemplateMetrics(metadata.page);
const mainSections = filterSections(page.main, data);
const sidebarSections = page.fullWidth ? [] : filterSections(page.sidebar, data);
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
const sidebarSections = useRenderedSectionIds(pageNodeKey, page.fullWidth ? [] : filterSections(page.sidebar, data));
const sections = [...mainSections, ...sidebarSections];
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors}>
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sections, { rowGap: metrics.sectionGap })}>
<SemanticRegionView region="main" style={composeStyles(styles.sections, { rowGap: metrics.sectionGap })}>
{sections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</SemanticRegionView>
</TemplateProvider>
</Page>
);
@@ -73,13 +89,13 @@ const Header = ({ styles }: ScizorHeaderProps) => {
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
<SemanticHeaderView style={styles.header}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<View style={styles.headerNameRule} />
<SemanticTemplatePartView partKeys={["header-name-rule"]} style={styles.headerNameRule} />
{basics.headline && <Text style={styles.headerHeadline}>{basics.headline}</Text>}
<View style={styles.headerContactRow}>
<SemanticContactListView style={styles.headerContactRow}>
<LocationContactItem location={basics.location} style={styles.headerContactItem} />
<EmailContactItem email={basics.email} style={styles.headerContactItem} />
<PhoneContactItem phone={basics.phone} style={styles.headerContactItem} />
@@ -87,11 +103,11 @@ const Header = ({ styles }: ScizorHeaderProps) => {
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.headerContactItem} />
))}
</View>
</SemanticContactListView>
</View>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
</View>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
</SemanticHeaderView>
);
};
@@ -0,0 +1,20 @@
import type { TemplateSemanticManifest } from "../../semantic/template-manifest";
export const scizorSemanticManifest = {
template: "scizor",
regions: [
{ name: "header", placement: "main", origins: [] },
{ name: "main", placement: "main", origins: ["main", "sidebar"] },
],
header: { region: "header", placement: "main" },
specialSummary: null,
parts: [
{
name: "header-name-rule",
key: "header-name-rule",
owner: { kind: "header", key: "header" },
binding: { type: "primitive", primitive: "View", source: "existing" },
route: { parent: "owner", at: { before: { kind: "headline" } } },
},
],
} as const satisfies TemplateSemanticManifest;
@@ -2,8 +2,12 @@ import type { Style } from "@react-pdf/types";
import type { CustomField } from "@reactive-resume/schema/resume/data";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import { View } from "#react-pdf-renderer";
import { resolvedPdfFlowProps } from "../../semantic/adapter";
import { useResolvedNode, useSemanticNodeKey, useSemanticNodeVisible } from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { getCustomFieldLinkUrl, getWebsiteDisplayText } from "./contact";
import { Icon, Link, Text } from "./primitives";
import { composeStyles } from "./styles";
type ContactStyle = Style | Style[];
@@ -12,11 +16,25 @@ type WebsiteDisplay = {
label?: string | undefined;
};
const useContactNodeKeys = (name: string, id?: string, primitiveNodeKey?: string) => {
const headerNodeKey = useSemanticNodeKey();
const contactListNodeKey = headerNodeKey ? semanticNodeKeys.contactList(headerNodeKey) : undefined;
const contactNodeKey = contactListNodeKey ? semanticNodeKeys.contactItem(contactListNodeKey, name, id) : undefined;
return {
contactNodeKey,
primitiveNodeKey: primitiveNodeKey ?? contactNodeKey,
fieldNodeKey: contactNodeKey ? semanticNodeKeys.field(contactNodeKey, name) : undefined,
iconNodeKey: contactNodeKey ? semanticNodeKeys.icon(contactNodeKey, "contact") : undefined,
};
};
type WebsiteContactItemProps = {
website: WebsiteDisplay;
style?: ContactStyle;
textStyle?: ContactStyle;
iconColor?: string;
primitiveNodeKey?: string | undefined;
};
type CustomFieldContactItemProps = {
@@ -24,37 +42,64 @@ type CustomFieldContactItemProps = {
style?: ContactStyle;
textStyle?: ContactStyle;
iconColor?: string;
primitiveNodeKey?: string | undefined;
};
export const WebsiteContactItem = ({ website, style, textStyle, iconColor }: WebsiteContactItemProps) => {
if (!website.url) return null;
export const WebsiteContactItem = ({
website,
style,
textStyle,
iconColor,
primitiveNodeKey,
}: WebsiteContactItemProps) => {
const keys = useContactNodeKeys("website", undefined, primitiveNodeKey);
const visible = useSemanticNodeVisible(keys.primitiveNodeKey);
if (!website.url || !visible) return null;
return (
<Link src={website.url} {...(style ? { style } : {})}>
<Icon name="globe" {...(iconColor ? { color: iconColor } : {})} />
<Text {...(textStyle ? { style: textStyle } : {})}>{getWebsiteDisplayText(website)}</Text>
<Link nodeKey={keys.primitiveNodeKey} src={website.url} {...(style ? { style } : {})}>
<Icon nodeKey={keys.iconNodeKey} name="globe" {...(iconColor ? { color: iconColor } : {})} />
<Text nodeKey={keys.fieldNodeKey} {...(textStyle ? { style: textStyle } : {})}>
{getWebsiteDisplayText(website)}
</Text>
</Link>
);
};
export const CustomFieldContactItem = ({ field, style, textStyle, iconColor }: CustomFieldContactItemProps) => {
export const CustomFieldContactItem = ({
field,
style,
textStyle,
iconColor,
primitiveNodeKey,
}: CustomFieldContactItemProps) => {
const linkUrl = getCustomFieldLinkUrl(field);
const keys = useContactNodeKeys("custom", field.id, primitiveNodeKey);
const resolved = useResolvedNode(keys.primitiveNodeKey);
const visible = useSemanticNodeVisible(keys.primitiveNodeKey);
const children = (
<>
<Icon name={field.icon as IconName} {...(iconColor ? { color: iconColor } : {})} />
<Text {...(textStyle ? { style: textStyle } : {})}>{field.text}</Text>
<Icon nodeKey={keys.iconNodeKey} name={field.icon as IconName} {...(iconColor ? { color: iconColor } : {})} />
<Text nodeKey={keys.fieldNodeKey} {...(textStyle ? { style: textStyle } : {})}>
{field.text}
</Text>
</>
);
if (!visible) return null;
if (linkUrl) {
return (
<Link src={linkUrl} {...(style ? { style } : {})}>
<Link nodeKey={keys.primitiveNodeKey} src={linkUrl} {...(style ? { style } : {})}>
{children}
</Link>
);
}
return <View {...(style ? { style } : {})}>{children}</View>;
return (
<View {...resolvedPdfFlowProps(resolved)} style={composeStyles(style, resolved.style)}>
{children}
</View>
);
};
type EmailContactItemProps = {
@@ -64,6 +109,7 @@ type EmailContactItemProps = {
iconColor?: string;
/** Override icon; defaults to "envelope". ditgar uses "at". */
iconName?: IconName;
primitiveNodeKey?: string | undefined;
};
export const EmailContactItem = ({
@@ -72,12 +118,17 @@ export const EmailContactItem = ({
textStyle,
iconColor,
iconName = "envelope",
primitiveNodeKey,
}: EmailContactItemProps) => {
if (!email) return null;
const keys = useContactNodeKeys("email", undefined, primitiveNodeKey);
const visible = useSemanticNodeVisible(keys.primitiveNodeKey);
if (!email || !visible) return null;
return (
<Link src={`mailto:${email}`} {...(style ? { style } : {})}>
<Icon name={iconName} {...(iconColor ? { color: iconColor } : {})} />
<Text {...(textStyle ? { style: textStyle } : {})}>{email}</Text>
<Link nodeKey={keys.primitiveNodeKey} src={`mailto:${email}`} {...(style ? { style } : {})}>
<Icon nodeKey={keys.iconNodeKey} name={iconName} {...(iconColor ? { color: iconColor } : {})} />
<Text nodeKey={keys.fieldNodeKey} {...(textStyle ? { style: textStyle } : {})}>
{email}
</Text>
</Link>
);
};
@@ -87,14 +138,19 @@ type PhoneContactItemProps = {
style?: ContactStyle;
textStyle?: ContactStyle;
iconColor?: string;
primitiveNodeKey?: string | undefined;
};
export const PhoneContactItem = ({ phone, style, textStyle, iconColor }: PhoneContactItemProps) => {
if (!phone) return null;
export const PhoneContactItem = ({ phone, style, textStyle, iconColor, primitiveNodeKey }: PhoneContactItemProps) => {
const keys = useContactNodeKeys("phone", undefined, primitiveNodeKey);
const visible = useSemanticNodeVisible(keys.primitiveNodeKey);
if (!phone || !visible) return null;
return (
<Link src={`tel:${phone}`} {...(style ? { style } : {})}>
<Icon name="phone" {...(iconColor ? { color: iconColor } : {})} />
<Text {...(textStyle ? { style: textStyle } : {})}>{phone}</Text>
<Link nodeKey={keys.primitiveNodeKey} src={`tel:${phone}`} {...(style ? { style } : {})}>
<Icon nodeKey={keys.iconNodeKey} name="phone" {...(iconColor ? { color: iconColor } : {})} />
<Text nodeKey={keys.fieldNodeKey} {...(textStyle ? { style: textStyle } : {})}>
{phone}
</Text>
</Link>
);
};
@@ -104,14 +160,26 @@ type LocationContactItemProps = {
style?: ContactStyle;
textStyle?: ContactStyle;
iconColor?: string;
primitiveNodeKey?: string | undefined;
};
export const LocationContactItem = ({ location, style, textStyle, iconColor }: LocationContactItemProps) => {
if (!location) return null;
export const LocationContactItem = ({
location,
style,
textStyle,
iconColor,
primitiveNodeKey,
}: LocationContactItemProps) => {
const keys = useContactNodeKeys("location", undefined, primitiveNodeKey);
const resolved = useResolvedNode(keys.primitiveNodeKey);
const visible = useSemanticNodeVisible(keys.primitiveNodeKey);
if (!location || !visible) return null;
return (
<View {...(style ? { style } : {})}>
<Icon name="map-pin" {...(iconColor ? { color: iconColor } : {})} />
<Text {...(textStyle ? { style: textStyle } : {})}>{location}</Text>
<View {...resolvedPdfFlowProps(resolved)} style={composeStyles(style, resolved.style)}>
<Icon nodeKey={keys.iconNodeKey} name="map-pin" {...(iconColor ? { color: iconColor } : {})} />
<Text nodeKey={keys.fieldNodeKey} {...(textStyle ? { style: textStyle } : {})}>
{location}
</Text>
</View>
);
};
+10 -3
View File
@@ -14,6 +14,7 @@ import type {
} from "./types";
import { createContext, use, useMemo } from "react";
import { useRender } from "../../context";
import { useSemanticRenderMode } from "../../semantic/context";
import { resolveStyleRuleSlot } from "./style-rules";
type TemplateContextValue = {
@@ -21,11 +22,13 @@ type TemplateContextValue = {
featureStyles: TemplateFeatureStyleSlots;
colors: TemplateColorRoles;
features: TemplateFeatures;
pageNodeKey: string;
};
type TemplateProviderProps = Omit<TemplateContextValue, "featureStyles" | "features" | "sectionTitleFallbacks"> & {
featureStyles?: TemplateFeatureStyleSlots;
features?: TemplateFeatures;
pageNodeKey: string;
children: ReactNode;
};
@@ -73,11 +76,12 @@ export const TemplateProvider = ({
featureStyles = EMPTY_FEATURE_STYLES,
colors,
features = EMPTY_FEATURES,
pageNodeKey,
children,
}: TemplateProviderProps) => {
const contextValue = useMemo<TemplateContextValue>(
() => ({ styles, featureStyles, colors, features }),
[colors, featureStyles, features, styles],
() => ({ styles, featureStyles, colors, features, pageNodeKey }),
[colors, featureStyles, features, pageNodeKey, styles],
);
return <TemplateContext.Provider value={contextValue}>{children}</TemplateContext.Provider>;
@@ -107,6 +111,8 @@ export const useTemplateFeature = (feature: keyof TemplateFeatures): boolean =>
export const useTemplatePlacement = () => use(TemplatePlacementContext);
export const useTemplatePageNodeKey = () => useTemplateContext().pageNodeKey;
const useTemplateStyleContext = (): TemplateStyleContextValue => {
const { colors } = useTemplateContext();
const placement = useTemplatePlacement();
@@ -124,8 +130,9 @@ export const useTemplateStyle = (slot: keyof TemplateStyleSlots): StyleInput =>
export const useSectionStyleRule = (slot: StyleSlot): StyleInput => {
const data = useRender();
const context = use(SectionStyleContext);
const mode = useSemanticRenderMode();
if (!context) return undefined;
if (!context || mode === "semantic") return undefined;
return resolveStyleRuleSlot(data, { ...context, slot });
};
@@ -3,6 +3,14 @@ import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import { resolveLevelDisplaySizes } from "@reactive-resume/schema/resume/level-display-sizes";
import { View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { resolvedPdfFlowProps } from "../../semantic/adapter";
import {
useResolvedNode,
useSemanticNodeBindings,
useSemanticNodeKey,
useSemanticNodeVisible,
} from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { resolveStyleFontSize } from "./icon-size";
import { getTemplateMetrics } from "./metrics";
@@ -17,6 +25,11 @@ type LevelDisplayProps = {
export const LevelDisplay = ({ level }: LevelDisplayProps) => {
const data = useRender();
const itemNodeKey = useSemanticNodeKey();
const levelNodeKey = itemNodeKey ? semanticNodeKeys.level(itemNodeKey) : undefined;
const resolved = useResolvedNode(levelNodeKey);
const visible = useSemanticNodeVisible(levelNodeKey);
const { resolveNode, isNodeVisible } = useSemanticNodeBindings();
const levelDesign = data.metadata.design.level;
const metrics = getTemplateMetrics(data.metadata.page);
const iconProps = useTemplateIconSlot("icon");
@@ -29,11 +42,11 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
const { decorationSize, levelIconExplicitSize } = resolveLevelDisplaySizes({
bodyFontSize: data.metadata.typography.body.fontSize,
iconFontSize: resolveStyleFontSize(iconRuleStyle),
levelFontSize: resolveStyleFontSize(levelRuleStyle),
levelFontSize: resolveStyleFontSize(levelRuleStyle, resolved.style),
});
const color = typeof iconProps.color === "string" ? iconProps.color : "#000000";
if (level === 0) return null;
if (level === 0 || !visible) return null;
if (levelDesign.type === "hidden") return null;
if (levelDesign.type === "icon" && levelDesign.icon === "") return null;
@@ -49,19 +62,25 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
return (
<View
{...resolvedPdfFlowProps(resolved)}
style={composeStyles(
{ flexDirection: "row", alignItems: "center", marginTop: 2, columnGap: gap },
levelContainerStyle,
levelRuleStyle,
resolved.style,
)}
>
{LEVEL_ITEM_KEYS.map((itemKey, index) => {
const isActive = index < level;
const decorationNodeKey = levelNodeKey ? semanticNodeKeys.icon(levelNodeKey, itemKey) : undefined;
const decorationResolved = resolveNode(decorationNodeKey);
if (!isNodeVisible(decorationNodeKey)) return null;
if (levelDesign.type === "icon") {
return (
<Icon
key={itemKey}
nodeKey={decorationNodeKey}
{...(levelIconExplicitSize === undefined ? {} : { size: levelIconExplicitSize })}
name={levelDesign.icon as IconName}
style={{ opacity: isActive ? 1 : 0.35 }}
@@ -73,6 +92,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
return (
<View
key={itemKey}
{...resolvedPdfFlowProps(decorationResolved)}
style={composeStyles(
{
flex: 1,
@@ -83,6 +103,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
},
levelItemStyle,
isActive ? levelItemActiveStyle : levelItemInactiveStyle,
decorationResolved.style,
)}
/>
);
@@ -108,6 +129,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
return (
<View
key={itemKey}
{...resolvedPdfFlowProps(decorationResolved)}
style={composeStyles(
{
width,
@@ -120,6 +142,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
},
levelItemStyle,
isActive ? levelItemActiveStyle : levelItemInactiveStyle,
decorationResolved.style,
)}
/>
);
@@ -7,7 +7,8 @@ const source = readFileSync(fileURLToPath(new URL("./primitives.tsx", import.met
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");
expect(source).toContain("{ hideUnderline: metadata.page.hideLinkUnderline }");
expect(source).toContain("resolved.style");
});
});
+396 -36
View File
@@ -1,108 +1,465 @@
import type { Style } from "@react-pdf/types";
import type { ComponentProps } from "react";
import type { ComponentProps, ReactNode } from "react";
import type { StyleInput } from "./styles";
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
import { Link as PdfLink, Text as PdfText, View } from "#react-pdf-renderer";
import { Children, isValidElement } from "react";
import { Image, Link as PdfLink, Text as PdfText, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { resolvedPdfFlowProps, resolvedPdfTextProps } from "../../semantic/adapter";
import {
projectRenderedChildren,
SemanticNodeKeyProvider,
useRenderedChildKeys,
useResolvedNode,
useSemanticNodeExists,
useSemanticNodeKey,
useSemanticNodeVisible,
} from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { useSectionStyleRule, useTemplateIconSlot, useTemplatePageNodeKey, useTemplateStyle } from "./context";
import { resolveIconSize } from "./icon-size";
import { safeTextStyle } from "./safe-text-style";
import { composeLinkStyles, composeStyles } from "./styles";
const asStyleInput = (style: unknown): StyleInput => style as StyleInput;
export const Div = ({ style, ...props }: ComponentProps<typeof View>) => {
type SemanticProps = {
nodeKey?: string | undefined;
semanticField?: string | undefined;
bindSemanticNode?: boolean | undefined;
bindCurrentNode?: boolean | undefined;
};
type SemanticLinkProps = SemanticProps & {
semanticRole?: string | undefined;
};
const getChildren = (props: object): ReactNode =>
"children" in props ? (props as { children?: ReactNode }).children : undefined;
const contactChildNodeKey = (contactListNodeKey: string, child: ReactNode): string | undefined => {
if (!isValidElement(child)) return;
const props = child.props as Record<string, unknown>;
if (typeof props.primitiveNodeKey === "string") return props.primitiveNodeKey;
if (typeof props.nodeKey === "string") return props.nodeKey;
if (typeof props.partKey === "string") return semanticTemplatePartNodeKey(contactListNodeKey, props.partKey);
if ("email" in props) return semanticNodeKeys.contactItem(contactListNodeKey, "email");
if ("phone" in props) return semanticNodeKeys.contactItem(contactListNodeKey, "phone");
if ("location" in props) return semanticNodeKeys.contactItem(contactListNodeKey, "location");
if ("website" in props) return semanticNodeKeys.contactItem(contactListNodeKey, "website");
if (typeof props.field === "object" && props.field !== null && "id" in props.field) {
return semanticNodeKeys.contactItem(contactListNodeKey, "custom", String(props.field.id));
}
};
const projectContactChildren = (
children: ReactNode,
contactListNodeKey: string,
renderedChildKeys: readonly string[],
): ReactNode => {
const authored = Children.toArray(children);
const entries = authored.flatMap((child) => {
const nodeKey = contactChildNodeKey(contactListNodeKey, child);
return nodeKey ? [{ nodeKey, value: child }] : [];
});
return entries.length === authored.length ? projectRenderedChildren(renderedChildKeys, entries) : authored;
};
const usePrimitiveNodeKey = ({
nodeKey,
semanticField,
bindSemanticNode = true,
bindCurrentNode = false,
children,
heading = false,
}: SemanticProps & { children?: ReactNode; heading?: boolean }) => {
const data = useRender();
const parentKey = useSemanticNodeKey();
if (!bindSemanticNode) return undefined;
if (bindCurrentNode) return parentKey;
if (nodeKey) return nodeKey;
if (semanticField && parentKey) return semanticNodeKeys.field(parentKey, semanticField);
if (!parentKey) return undefined;
if (parentKey.endsWith("/header")) {
if (heading && children === data.basics.name) return semanticNodeKeys.headerPart(parentKey, "name");
if (!heading && children === data.basics.headline) return semanticNodeKeys.headerPart(parentKey, "headline");
}
if (heading && parentKey.includes("/section-") && !parentKey.includes("/section-items")) {
return semanticNodeKeys.sectionHeading(parentKey);
}
return undefined;
};
export const Div = ({
style,
nodeKey,
semanticField,
bindSemanticNode,
bindCurrentNode,
...props
}: ComponentProps<typeof View> & SemanticProps) => {
const divStyle = useTemplateStyle("div");
return <View style={composeStyles(divStyle, style as Style | Style[] | undefined)} {...props} />;
};
export const Text = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const textStyle = useTemplateStyle("text");
const textRuleStyle = useSectionStyleRule("text");
return <PdfText style={composeStyles(textStyle, textRuleStyle, asStyleInput(style), safeTextStyle)} {...props} />;
};
export const Heading = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const headingStyle = useTemplateStyle("heading");
const headingRuleStyle = useSectionStyleRule("heading");
const resolvedNodeKey = usePrimitiveNodeKey({
nodeKey,
semanticField,
bindSemanticNode,
bindCurrentNode,
children: getChildren(props),
});
const resolved = useResolvedNode(resolvedNodeKey);
const visible = useSemanticNodeVisible(resolvedNodeKey);
if (!visible) return null;
return (
<PdfText style={composeStyles(headingStyle, headingRuleStyle, asStyleInput(style), safeTextStyle)} {...props} />
<View
{...props}
{...resolvedPdfFlowProps(resolved)}
style={composeStyles(divStyle, style as Style | Style[] | undefined, resolved.style)}
/>
);
};
export const Link = ({ style, ...props }: ComponentProps<typeof PdfLink>) => {
export const Text = ({
style,
nodeKey,
semanticField,
bindSemanticNode,
bindCurrentNode: _bindCurrentNode,
...props
}: ComponentProps<typeof PdfText> & SemanticProps) => {
const textStyle = useTemplateStyle("text");
const textRuleStyle = useSectionStyleRule("text");
const resolvedNodeKey = usePrimitiveNodeKey({
nodeKey,
semanticField,
bindSemanticNode,
children: getChildren(props),
});
const resolved = useResolvedNode(resolvedNodeKey);
const visible = useSemanticNodeVisible(resolvedNodeKey);
if (!visible) return null;
return (
<PdfText
{...props}
{...resolvedPdfTextProps(resolved)}
style={composeStyles(textStyle, textRuleStyle, asStyleInput(style), resolved.style, safeTextStyle)}
/>
);
};
export const Heading = ({
style,
nodeKey,
semanticField,
bindSemanticNode,
bindCurrentNode: _bindCurrentNode,
...props
}: ComponentProps<typeof PdfText> & SemanticProps) => {
const headingStyle = useTemplateStyle("heading");
const headingRuleStyle = useSectionStyleRule("heading");
const resolvedNodeKey = usePrimitiveNodeKey({
nodeKey,
semanticField,
bindSemanticNode,
children: getChildren(props),
heading: true,
});
const resolved = useResolvedNode(resolvedNodeKey);
const visible = useSemanticNodeVisible(resolvedNodeKey);
if (!visible) return null;
return (
<PdfText
{...props}
{...resolvedPdfTextProps(resolved)}
style={composeStyles(headingStyle, headingRuleStyle, asStyleInput(style), resolved.style, safeTextStyle)}
/>
);
};
export const Link = ({
style,
nodeKey,
semanticField,
semanticRole,
bindSemanticNode: _bindSemanticNode,
bindCurrentNode: _bindCurrentNode,
...props
}: ComponentProps<typeof PdfLink> & SemanticLinkProps) => {
const { metadata } = useRender();
const linkStyle = useTemplateStyle("link");
const linkRuleStyle = useSectionStyleRule("link");
const parentKey = useSemanticNodeKey();
const resolvedNodeKey =
nodeKey ??
(semanticRole && parentKey ? semanticNodeKeys.link(parentKey, semanticRole) : undefined) ??
(semanticField && parentKey ? semanticNodeKeys.field(parentKey, semanticField) : undefined);
const resolved = useResolvedNode(resolvedNodeKey);
const visible = useSemanticNodeVisible(resolvedNodeKey);
if (!visible) return null;
return (
<PdfLink
style={composeLinkStyles(
{ hideUnderline: metadata.page.hideLinkUnderline },
linkStyle,
linkRuleStyle,
asStyleInput(style),
{...props}
{...resolvedPdfTextProps(resolved)}
style={composeStyles(
composeLinkStyles(
{ hideUnderline: metadata.page.hideLinkUnderline },
linkStyle,
linkRuleStyle,
asStyleInput(style),
),
resolved.style,
safeTextStyle,
)}
{...props}
/>
);
};
export const Small = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
export const Small = ({
style,
nodeKey,
semanticField,
bindSemanticNode,
bindCurrentNode: _bindCurrentNode,
...props
}: ComponentProps<typeof PdfText> & SemanticProps) => {
const textStyle = useTemplateStyle("text");
const smallStyle = useTemplateStyle("small");
const secondaryTextRuleStyle = useSectionStyleRule("secondaryText");
const resolvedNodeKey = usePrimitiveNodeKey({
nodeKey,
semanticField,
bindSemanticNode,
children: getChildren(props),
});
const resolved = useResolvedNode(resolvedNodeKey);
const visible = useSemanticNodeVisible(resolvedNodeKey);
if (!visible) return null;
return (
<PdfText
style={composeStyles(textStyle, smallStyle, secondaryTextRuleStyle, asStyleInput(style), safeTextStyle)}
{...props}
{...resolvedPdfTextProps(resolved)}
style={composeStyles(
textStyle,
smallStyle,
secondaryTextRuleStyle,
asStyleInput(style),
resolved.style,
safeTextStyle,
)}
/>
);
};
export const Bold = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
export const Bold = ({
style,
nodeKey,
semanticField,
bindSemanticNode,
bindCurrentNode: _bindCurrentNode,
...props
}: ComponentProps<typeof PdfText> & SemanticProps) => {
const textStyle = useTemplateStyle("text");
const boldStyle = useTemplateStyle("bold");
const textRuleStyle = useSectionStyleRule("text");
const resolvedNodeKey = usePrimitiveNodeKey({
nodeKey,
semanticField,
bindSemanticNode,
children: getChildren(props),
});
const resolved = useResolvedNode(resolvedNodeKey);
const visible = useSemanticNodeVisible(resolvedNodeKey);
if (!visible) return null;
return (
<PdfText
style={composeStyles(textStyle, textRuleStyle, boldStyle, asStyleInput(style), safeTextStyle)}
{...props}
{...resolvedPdfTextProps(resolved)}
style={composeStyles(textStyle, textRuleStyle, boldStyle, asStyleInput(style), resolved.style, safeTextStyle)}
/>
);
};
export const Icon = ({ style, size: sizeProp, ...props }: ComponentProps<typeof PhosphorIcon>) => {
export const Icon = ({
style,
size: sizeProp,
nodeKey,
...props
}: ComponentProps<typeof PhosphorIcon> & { nodeKey?: string | undefined }) => {
const { style: iconStyle, size: templateSize, ...iconProps } = useTemplateIconSlot("icon");
const iconRuleStyle = useSectionStyleRule("icon");
const composedStyle = composeStyles(asStyleInput(iconStyle), iconRuleStyle, asStyleInput(style));
const templateIconSize =
typeof templateSize === "number" || typeof templateSize === "string" ? templateSize : undefined;
const parentKey = useSemanticNodeKey();
const resolvedNodeKey = nodeKey ?? (parentKey ? semanticNodeKeys.icon(parentKey, "item") : undefined);
const resolved = useResolvedNode(resolvedNodeKey);
const visible = useSemanticNodeVisible(resolvedNodeKey);
const resolvedSize =
resolveIconSize({
size: sizeProp,
styles: [iconRuleStyle, asStyleInput(style)],
styles: [iconRuleStyle, asStyleInput(style), resolved.style],
}) ?? templateIconSize;
if (iconProps.display === "none") return null;
if (iconProps.display === "none" || !visible) return null;
return (
<PhosphorIcon
{...iconProps}
{...props}
{...(resolvedSize === undefined ? {} : { size: resolvedSize })}
style={composedStyle}
style={composeStyles(composedStyle, resolved.style)}
/>
);
};
export const SectionHeadingIcon = ({ style, size: sizeProp, ...props }: ComponentProps<typeof PhosphorIcon>) => {
export const SemanticHeaderView = ({ style, ...props }: ComponentProps<typeof View>) => {
const pageNodeKey = useTemplatePageNodeKey();
const regionNodeKey = semanticNodeKeys.region(pageNodeKey, "header");
const nodeKey = semanticNodeKeys.header(regionNodeKey);
const regionResolved = useResolvedNode(regionNodeKey);
const resolved = useResolvedNode(nodeKey);
const regionVisible = useSemanticNodeVisible(regionNodeKey);
const headerVisible = useSemanticNodeVisible(nodeKey);
if (!regionVisible || !headerVisible) return null;
return (
<SemanticNodeKeyProvider nodeKey={nodeKey}>
<View
{...props}
{...resolvedPdfFlowProps(regionResolved)}
{...resolvedPdfFlowProps(resolved)}
style={composeStyles(asStyleInput(style), regionResolved.style, resolved.style)}
/>
</SemanticNodeKeyProvider>
);
};
export const SemanticRegionView = ({ region, style, ...props }: ComponentProps<typeof View> & { region: string }) => {
const pageNodeKey = useTemplatePageNodeKey();
const nodeKey = semanticNodeKeys.region(pageNodeKey, region);
const resolved = useResolvedNode(nodeKey);
const exists = useSemanticNodeExists(nodeKey);
const visible = useSemanticNodeVisible(nodeKey);
if (exists && !visible) return null;
return (
<View {...props} {...resolvedPdfFlowProps(resolved)} style={composeStyles(asStyleInput(style), resolved.style)} />
);
};
export const SemanticRegionTemplatePartView = ({
region,
partKeys,
style,
...props
}: ComponentProps<typeof View> & { region: string; partKeys: readonly string[] }) => {
const pageNodeKey = useTemplatePageNodeKey();
const regionNodeKey = semanticNodeKeys.region(pageNodeKey, region);
const partNodeKey = semanticTemplatePartNodeKey(regionNodeKey, ...partKeys);
const regionResolved = useResolvedNode(regionNodeKey);
const partResolved = useResolvedNode(partNodeKey);
const regionVisible = useSemanticNodeVisible(regionNodeKey);
const partVisible = useSemanticNodeVisible(partNodeKey);
if (!regionVisible || !partVisible) return null;
return (
<View
{...props}
{...resolvedPdfFlowProps(regionResolved)}
{...resolvedPdfFlowProps(partResolved)}
style={composeStyles(asStyleInput(style), regionResolved.style, partResolved.style)}
/>
);
};
export const SemanticContactListView = ({ style, ...props }: ComponentProps<typeof View>) => {
const headerNodeKey = useSemanticNodeKey();
const nodeKey = headerNodeKey ? semanticNodeKeys.contactList(headerNodeKey) : undefined;
const resolved = useResolvedNode(nodeKey);
const renderedChildKeys = useRenderedChildKeys(nodeKey);
const visible = useSemanticNodeVisible(nodeKey);
if (!visible) return null;
const children =
nodeKey && renderedChildKeys ? projectContactChildren(props.children, nodeKey, renderedChildKeys) : props.children;
return (
<View {...props} {...resolvedPdfFlowProps(resolved)} style={composeStyles(asStyleInput(style), resolved.style)}>
{children}
</View>
);
};
export const SemanticContactRowView = ({
partKey,
style,
...props
}: ComponentProps<typeof View> & { partKey: string }) => {
const headerNodeKey = useSemanticNodeKey();
const contactListNodeKey = headerNodeKey ? semanticNodeKeys.contactList(headerNodeKey) : undefined;
const nodeKey = semanticTemplatePartNodeKey(contactListNodeKey, partKey);
const resolved = useResolvedNode(nodeKey);
const renderedChildKeys = useRenderedChildKeys(nodeKey);
const visible = useSemanticNodeVisible(nodeKey);
if (!visible) return null;
const children =
contactListNodeKey && renderedChildKeys
? projectContactChildren(props.children, contactListNodeKey, renderedChildKeys)
: props.children;
return (
<View {...props} {...resolvedPdfFlowProps(resolved)} style={composeStyles(asStyleInput(style), resolved.style)}>
{children}
</View>
);
};
export function semanticTemplatePartNodeKey(ownerNodeKey: string | undefined, ...partKeys: string[]) {
return ownerNodeKey ? partKeys.reduce((key, part) => `${key}/template-part-${part}`, ownerNodeKey) : undefined;
}
export const SemanticTemplatePartView = ({
ownerNodeKey,
partKeys,
style,
...props
}: ComponentProps<typeof View> & { ownerNodeKey?: string | undefined; partKeys: readonly string[] }) => {
const contextualOwnerNodeKey = useSemanticNodeKey();
const nodeKey = semanticTemplatePartNodeKey(ownerNodeKey ?? contextualOwnerNodeKey, ...partKeys);
const resolved = useResolvedNode(nodeKey);
const visible = useSemanticNodeVisible(nodeKey);
if (!visible) return null;
return (
<View {...props} {...resolvedPdfFlowProps(resolved)} style={composeStyles(asStyleInput(style), resolved.style)} />
);
};
export const SemanticHeaderPicture = ({ style, ...props }: ComponentProps<typeof Image>) => {
const pageNodeKey = useTemplatePageNodeKey();
const headerNodeKey = useSemanticNodeKey() ?? semanticNodeKeys.header(semanticNodeKeys.region(pageNodeKey, "header"));
const nodeKey = headerNodeKey ? semanticNodeKeys.headerPart(headerNodeKey, "picture") : undefined;
const resolved = useResolvedNode(nodeKey);
const visible = useSemanticNodeVisible(nodeKey);
if (!visible) return null;
return <Image {...props} style={composeStyles(asStyleInput(style), resolved.style)} />;
};
export const SectionHeadingIcon = ({
style,
size: sizeProp,
nodeKey,
...props
}: ComponentProps<typeof PhosphorIcon> & { nodeKey?: string | undefined }) => {
const data = useRender();
const { style: sectionIconStyle, ...sectionIconProps } = useTemplateIconSlot("sectionHeadingIcon");
const { style: fallbackIconStyle, ...fallbackIconProps } = useTemplateIconSlot("icon");
@@ -127,13 +484,16 @@ export const SectionHeadingIcon = ({ style, size: sizeProp, ...props }: Componen
}) ??
templateIconSize ??
headingFontSize;
const resolved = useResolvedNode(nodeKey);
const visible = useSemanticNodeVisible(nodeKey);
if (!visible) return null;
return (
<PhosphorIcon
{...iconPropsWithoutDisplay}
{...props}
{...(resolvedSize === undefined ? {} : { size: resolvedSize })}
style={composeStyles(asStyleInput(iconStyle), asStyleInput(style))}
style={composeStyles(asStyleInput(iconStyle), asStyleInput(style), resolved.style)}
/>
);
};
@@ -109,6 +109,10 @@ describe("normalizeRichTextHtml", () => {
it("does not double-wrap inline tags inside block elements", () => {
expect(normalizeRichTextHtml("<p><strong>x</strong></p>")).toBe("<p><strong>x</strong></p>");
});
it("normalizes RTL pseudo-bullets into anchored list items in the shared HTML path", () => {
expect(normalizeRichTextHtml("<p>- א<br>- ב</p>", { direction: "rtl" })).toBe("<ul><li>‏א</li><li>‏ב</li></ul>");
});
});
describe("convertPseudoBulletParagraphs", () => {
@@ -1,8 +1,10 @@
import type { HTMLElement, Node } from "node-html-parser";
import { NodeType, parse } from "node-html-parser";
import { isDarkColor } from "@reactive-resume/utils/color";
import { getRichTextSemanticKind, getRichTextSemanticNodeKey } from "../../semantic/rich-text-keys";
export const richTextMarkClassName = "rr-pdf-mark";
export const richTextSemanticNodeKeyAttribute = "data-resume-semantic-node-key";
const inlineTags = new Set([
"a",
@@ -121,7 +123,14 @@ export const convertPseudoBulletParagraphs = (html: string): string =>
return converted ?? full;
});
export const normalizeRichTextHtml = (html: string): string => {
type NormalizeRichTextHtmlOptions = {
direction?: "ltr" | "rtl";
};
export const normalizeRichTextHtml = (
html: string,
{ direction = "ltr" }: NormalizeRichTextHtmlOptions = {},
): string => {
const root = parse(html.trim(), { comment: false });
const normalized: string[] = [];
let inlineNodes: string[] = [];
@@ -151,5 +160,71 @@ export const normalizeRichTextHtml = (html: string): string => {
flushInlineNodes();
return normalized.join("");
const normalizedHtml = normalized.join("");
if (direction !== "rtl") return normalizedHtml;
// RTL pseudo-bullets must become real list items before both the semantic
// descriptor and renderer traverse the HTML. RLM anchors each independent
// react-pdf-html text frame without changing element ancestry or indices.
return convertPseudoBulletParagraphs(normalizedHtml).replace(
/<(p|li)\b([^>]*)>/gi,
(_match, tag, rest) => `<${tag}${rest}>`,
);
};
export const parseNormalizedRichTextHtml = (html: string, options?: NormalizeRichTextHtmlOptions) =>
parse(normalizeRichTextHtml(html, options), { comment: false });
export const projectNormalizedRichTextHtml = (
html: string,
rootNodeKey: string,
renderedChildKeysFor: (nodeKey: string) => readonly string[] | undefined,
): string => {
const root = parse(html, { comment: false });
const elements = root.querySelectorAll("*");
for (const element of elements) {
if (!getRichTextSemanticKind(element, richTextMarkClassName)) continue;
element.setAttribute(
richTextSemanticNodeKeyAttribute,
getRichTextSemanticNodeKey(rootNodeKey, element, richTextMarkClassName),
);
}
const visit = (parent: HTMLElement, parentNodeKey: string) => {
for (const child of parent.childNodes) {
if (!isElement(child)) continue;
const childNodeKey = child.getAttribute(richTextSemanticNodeKeyAttribute);
const childContentNodeKey =
childNodeKey && getTagName(child) === "li"
? `${childNodeKey}/list-item-content-0`
: (childNodeKey ?? parentNodeKey);
visit(child, childContentNodeKey);
}
const keyedChildren = parent.childNodes.flatMap((child) => {
if (!isElement(child)) return [];
const nodeKey = child.getAttribute(richTextSemanticNodeKeyAttribute);
return nodeKey ? [{ nodeKey, child }] : [];
});
if (keyedChildren.length === 0) return;
const renderedChildKeys = renderedChildKeysFor(parentNodeKey);
if (!renderedChildKeys) return;
const childByNodeKey = new Map(keyedChildren.map(({ nodeKey, child }) => [nodeKey, child]));
const projected = renderedChildKeys.flatMap((nodeKey) => {
const child = childByNodeKey.get(nodeKey);
return child ? [child] : [];
});
let projectedIndex = 0;
const nextChildren = parent.childNodes.flatMap((child) => {
if (!isElement(child) || !child.getAttribute(richTextSemanticNodeKeyAttribute)) return [child];
const projectedChild = projected[projectedIndex++];
return projectedChild ? [projectedChild] : [];
});
parent.set_content(nextChildren);
};
visit(root, rootNodeKey);
return root.toString();
};
@@ -7,6 +7,7 @@ import {
isRichTextElementInsideListItem,
stripRichTextVerticalMargins,
} from "./rich-text-spacing";
import { safeTextStyle } from "./safe-text-style";
import { composeStyles } from "./styles";
export const toRichTextStyleArray = (style: Style | Style[] | undefined): Style[] => {
@@ -20,18 +21,22 @@ type RichTextParagraphRendererProps = {
children: ReactNode;
element: Parameters<typeof isRichTextElementInsideListItem>[0];
style: Style | Style[] | undefined;
semanticStyle?: Style | Style[] | undefined;
rtl?: boolean;
rtlTextWrapStyle?: Style | undefined;
applyRtlDirection?: (node: ReactNode) => ReactNode;
textProps?: Record<string, unknown>;
};
export const renderRichTextParagraph = ({
element,
style,
semanticStyle,
children,
rtl,
rtlTextWrapStyle,
applyRtlDirection,
textProps,
}: RichTextParagraphRendererProps) => {
const paragraphStyles = isRichTextElementInsideListItem(element)
? toRichTextStyleArray(style).map(stripRichTextVerticalMargins)
@@ -41,9 +46,11 @@ export const renderRichTextParagraph = ({
paragraphStyles,
getRichTextEdgeTrimStyle(element),
rtl ? rtlTextWrapStyle : undefined,
semanticStyle,
safeTextStyle,
);
const content = rtl && applyRtlDirection ? applyRtlDirection(children) : children;
return createElement(PdfText, { style: composedStyle }, content);
return createElement(PdfText, { ...textProps, style: composedStyle }, content);
};
@@ -10,6 +10,8 @@ import { createRichTextStylesheet } from "./rich-text-stylesheet";
type PdfElement = ReactElement<{ children?: unknown; style?: unknown }>;
const getPdfElementProps = (element: unknown) => (element as PdfElement).props;
const mergeStyle = (style: unknown): Record<string, unknown> =>
Object.assign({}, ...(Array.isArray(style) ? style : style ? [style] : []));
describe("normalizeRichTextHtml", () => {
it("wraps top-level inline rich text in a paragraph", () => {
@@ -50,6 +52,33 @@ describe("renderRichTextParagraph", () => {
expect(rendered.type).toBe(PdfText);
expect(props.children).toEqual(["Plain ", expect.any(Object), " text"]);
});
it("keeps semantic paragraph margins after prose edge trimming while preserving text safety", () => {
const paragraph = parse("<p>First</p><p>Second</p>").querySelector("p");
if (!paragraph) throw new Error("Expected paragraph to exist.");
const rendered = renderRichTextParagraph({
element: paragraph,
style: { marginTop: 4, marginBottom: 4 },
semanticStyle: {
marginTop: 20,
marginBottom: 21,
minWidth: 72,
maxWidth: 72,
flexShrink: 0,
},
children: "First",
});
expect(mergeStyle(getPdfElementProps(rendered).style)).toMatchObject({
marginTop: 20,
marginBottom: 21,
minWidth: 0,
maxWidth: "100%",
flexShrink: 1,
});
});
});
describe("createRichTextStylesheet", () => {
+169 -20
View File
@@ -2,10 +2,25 @@ import type { Style } from "@react-pdf/types";
import type { ReactElement, ReactNode } from "react";
import { cloneElement, isValidElement } from "react";
import { Html } from "react-pdf-html";
import { Text as PdfText, View } from "#react-pdf-renderer";
import { Link as PdfLink, Text as PdfText, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { resolvedPdfFlowProps, resolvedPdfTextProps } from "../../semantic/adapter";
import {
projectRenderedChildren,
useResolvedNode,
useSemanticNodeBindings,
useSemanticNodeKey,
useSemanticNodeVisible,
} from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { getRichTextSemanticNodeKey } from "../../semantic/rich-text-keys";
import { useSectionStyleRule, useTemplateStyle } from "./context";
import { convertPseudoBulletParagraphs, normalizeRichTextHtml } from "./rich-text-html";
import {
normalizeRichTextHtml,
projectNormalizedRichTextHtml,
richTextMarkClassName,
richTextSemanticNodeKeyAttribute,
} from "./rich-text-html";
import { renderRichTextParagraph, toRichTextStyleArray } from "./rich-text-renderers";
import {
createRichTextProseSpacing,
@@ -24,6 +39,7 @@ const richListItemContentStackStyle = {
type RichTextProps = {
children: string;
semanticField?: string | undefined;
};
// react-pdf textkit reads BiDi base direction from each run's own `direction` attribute
@@ -57,8 +73,18 @@ const applyRtlDirectionRecursively = (node: ReactNode): ReactNode => {
return cloneElement(element, { style: nextStyle }, nextChildren);
};
export const RichText = ({ children }: RichTextProps) => {
export const RichText = ({ children, semanticField }: RichTextProps) => {
const { metadata, rtl } = useRender();
const parentNodeKey = useSemanticNodeKey();
const fieldNodeKey =
parentNodeKey && semanticField ? semanticNodeKeys.field(parentNodeKey, semanticField) : undefined;
const richTextNodeKey =
fieldNodeKey && semanticField ? semanticNodeKeys.richText(fieldNodeKey, semanticField) : undefined;
const fieldResolved = useResolvedNode(fieldNodeKey);
const fieldVisible = useSemanticNodeVisible(fieldNodeKey);
const richTextResolved = useResolvedNode(richTextNodeKey);
const richTextVisible = useSemanticNodeVisible(richTextNodeKey);
const { resolveNode, isNodeVisible, renderedChildKeysFor } = useSemanticNodeBindings();
const rtlTextWrapStyle: Style | undefined = rtl ? { direction: "rtl", textAlign: "right" } : undefined;
const boldStyle = useTemplateStyle("bold");
@@ -82,29 +108,120 @@ export const RichText = ({ children }: RichTextProps) => {
);
const proseSpacing = createRichTextProseSpacing(bodyLineHeight);
const normalized = normalizeRichTextHtml(children);
// RTL-only: pseudo-bullets share one BiDi paragraph across <br>, causing chars to
// bleed between visual lines. Real <li> items are independent BiDi paragraphs.
const withBullets = normalized && rtl ? convertPseudoBulletParagraphs(normalized) : normalized;
// Inject U+200F (RLM) after each <p>/<li> opener to anchor BiDi base direction
// on the inner styleless <Text> frame react-pdf-html creates.
const html =
withBullets && rtl
? withBullets.replace(/<(p|li)\b([^>]*)>/gi, (_match, tag, rest) => `<${tag}${rest}>`)
: withBullets;
const normalizedHtml = normalizeRichTextHtml(children, { direction: rtl ? "rtl" : "ltr" });
const html = richTextNodeKey
? projectNormalizedRichTextHtml(normalizedHtml, richTextNodeKey, renderedChildKeysFor)
: normalizedHtml;
if (!html) return null;
if (!html || !fieldVisible || !richTextVisible) return null;
const keyFor = (element: Parameters<typeof getRichTextSemanticNodeKey>[1]) =>
element.getAttribute(richTextSemanticNodeKeyAttribute) ??
(richTextNodeKey ? getRichTextSemanticNodeKey(richTextNodeKey, element, richTextMarkClassName) : undefined);
const resolvedFor = (element: Parameters<typeof getRichTextSemanticNodeKey>[1]) => resolveNode(keyFor(element));
const renderText = ({
element,
style,
children: textChildren,
}: {
element: Parameters<typeof getRichTextSemanticNodeKey>[1];
style: Style[];
children: ReactNode;
}) => {
const nodeKey = keyFor(element);
const resolved = resolveNode(nodeKey);
const visible = isNodeVisible(nodeKey);
if (!visible) return null;
return (
<PdfText {...resolvedPdfTextProps(resolved)} style={composeStyles(style, resolved.style, safeTextStyle)}>
{textChildren}
</PdfText>
);
};
const renderView = ({
element,
style,
children: viewChildren,
}: {
element: Parameters<typeof getRichTextSemanticNodeKey>[1];
style: Style[];
children: ReactNode;
}) => {
const nodeKey = keyFor(element);
const resolved = resolveNode(nodeKey);
const visible = isNodeVisible(nodeKey);
if (!visible) return null;
return (
<View {...resolvedPdfFlowProps(resolved)} style={composeStyles(style, resolved.style)}>
{viewChildren}
</View>
);
};
return (
<Html
resetStyles
{...resolvedPdfFlowProps(richTextResolved)}
style={composeStyles(fieldResolved.style, richTextResolved.style)}
renderers={{
b: ({ children }) => (
<PdfText style={composeStyles(boldStyle, richBoldRuleStyle, safeTextStyle)}>{children}</PdfText>
),
h1: renderText,
h2: renderText,
h3: renderText,
h4: renderText,
h5: renderText,
h6: renderText,
blockquote: renderView,
ul: renderView,
ol: renderView,
b: renderText,
strong: renderText,
em: renderText,
i: renderText,
u: renderText,
s: renderText,
strike: renderText,
code: renderText,
span: renderText,
mark: renderText,
a: ({ element, style, children: linkChildren }) => {
const nodeKey = keyFor(element);
const resolved = resolvedFor(element);
if (!isNodeVisible(nodeKey)) return null;
return (
<PdfLink
{...resolvedPdfTextProps(resolved)}
src={element.attributes.href ?? ""}
style={composeStyles(style, resolved.style, safeTextStyle)}
>
{linkChildren}
</PdfLink>
);
},
br: ({ element, style }) => {
const resolved = resolvedFor(element);
return (
<PdfText
{...resolvedPdfTextProps(resolved)}
wrap={false}
style={composeStyles(style, resolved.style, safeTextStyle)}
>
{"\n"}
</PdfText>
);
},
hr: ({ element, style }) => {
const nodeKey = keyFor(element);
const resolved = resolvedFor(element);
if (!isNodeVisible(nodeKey)) return null;
return <View {...resolvedPdfFlowProps(resolved)} style={composeStyles(style, resolved.style)} />;
},
p: (props) => {
const resolved = resolvedFor(props.element);
const paragraphProps = {
...props,
style: props.style,
semanticStyle: resolved.style,
textProps: resolvedPdfTextProps(resolved),
rtl,
...(rtlTextWrapStyle ? { rtlTextWrapStyle } : {}),
...(rtl ? { applyRtlDirection: applyRtlDirectionRecursively } : {}),
@@ -113,6 +230,16 @@ export const RichText = ({ children }: RichTextProps) => {
return renderRichTextParagraph(paragraphProps);
},
li: ({ element, style, children }) => {
const nodeKey = keyFor(element);
const itemResolved = resolvedFor(element);
if (!isNodeVisible(nodeKey)) return null;
const itemNodeKey = nodeKey;
const markerNodeKey = itemNodeKey ? semanticNodeKeys.richTextNode(itemNodeKey, "list-marker", 0) : undefined;
const contentNodeKey = itemNodeKey
? semanticNodeKeys.richTextNode(itemNodeKey, "list-item-content", 0)
: undefined;
const markerResolved = resolveNode(markerNodeKey);
const contentResolved = resolveNode(contentNodeKey);
const isOrderedList = isRichTextElementInsideOrderedList(element);
const marker = isOrderedList ? `${element.indexOfType + 1}.` : "•";
const itemStyles = toRichTextStyleArray(style);
@@ -121,8 +248,11 @@ export const RichText = ({ children }: RichTextProps) => {
const markerNode = (
<PdfText
key="marker"
minPresenceAhead={bodyLineHeight ?? metadata.typography.body.lineHeight}
style={composeStyles(richListItemMarkerStyle)}
{...resolvedPdfTextProps(markerResolved)}
minPresenceAhead={
markerResolved.minPresenceAhead ?? bodyLineHeight ?? metadata.typography.body.lineHeight
}
style={composeStyles(richListItemMarkerStyle, markerResolved.style)}
>
{marker}
</PdfText>
@@ -132,10 +262,12 @@ export const RichText = ({ children }: RichTextProps) => {
const contentNode = rtl ? (
<PdfText
key="content"
{...resolvedPdfTextProps(contentResolved)}
style={composeStyles(
richListItemContentStyle,
richListItemContentRuleStyle,
contentItemStyles,
contentResolved.style,
safeTextStyle,
rtlTextWrapStyle,
)}
@@ -145,10 +277,12 @@ export const RichText = ({ children }: RichTextProps) => {
) : (
<View
key="content"
{...resolvedPdfFlowProps(contentResolved)}
style={composeStyles(
richListItemContentStyle,
richListItemContentRuleStyle,
contentItemStyles,
contentResolved.style,
richListItemContentStackStyle,
safeTextStyle,
)}
@@ -156,19 +290,34 @@ export const RichText = ({ children }: RichTextProps) => {
{children}
</View>
);
const authoredChildren = rtl
? [
{ nodeKey: contentNodeKey ?? "content", value: contentNode },
{ nodeKey: markerNodeKey ?? "marker", value: markerNode },
]
: [
{ nodeKey: markerNodeKey ?? "marker", value: markerNode },
{ nodeKey: contentNodeKey ?? "content", value: contentNode },
];
const renderedChildren = projectRenderedChildren(
renderedChildKeysFor(itemNodeKey),
authoredChildren.filter(({ nodeKey }) => isNodeVisible(nodeKey)),
);
// Yoga ignores `flexDirection`/`direction` on rows inside react-pdf-html's <ul>
// (works fine for split-row/contact-list). Swap DOM order to position the marker.
return (
<View
{...resolvedPdfFlowProps(itemResolved)}
style={composeStyles(
richListItemRowStyle,
richListItemRowRuleStyle,
itemStyles,
getRichTextEdgeTrimStyle(element),
itemResolved.style,
)}
>
{rtl ? [contentNode, markerNode] : [markerNode, contentNode]}
{renderedChildren}
</View>
);
},
@@ -16,15 +16,20 @@ describe("ExperienceSection", () => {
describe("ItemTitle", () => {
it("renders award titles without the bold style", () => {
expect(source).toContain("const ItemTitle = ({ children, website, bold = true }: ItemTitleProps)");
expect(source).toContain("const title = bold ? <Bold>{children}</Bold> : <Text>{children}</Text>;");
expect(source).toContain("<ItemTitle website={item.website} bold={false}>");
expect(source).toContain("const ItemTitle = ({ children, website, field, bold = true }: ItemTitleProps)");
expect(source).toContain(
"const title = bold ? <Bold semanticField={field}>{children}</Bold> : <Text semanticField={field}>{children}</Text>;",
);
expect(source).toContain('<ItemTitle field="title" website={item.website} bold={false}>');
});
});
describe("SectionShell", () => {
it("keeps section and heading style rules when section heading icons are hidden", () => {
expect(source).toContain("<View style={composeStyles(sectionStyle, sectionRuleStyle)} {...breakProps}>");
expect(source).toContain(
"const resolvedSectionStyle = composeStyles(sectionStyle, sectionRuleStyle, resolved.style)",
);
expect(source).toContain("<View style={resolvedSectionStyle} {...flowProps}>");
expect(source).toContain("<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>");
});
File diff suppressed because it is too large Load Diff