diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md b/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md index a8f3a5b4..6cba35f8 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md @@ -13,6 +13,60 @@ This guide answers the **"how do I…"** questions. --- +## The scaffold is persona-neutral + +Nothing in the v2 API assumes a software-developer audience. The +sample data shipped with the package happens to be a developer CV +(Jordan Rivera, "Platform Engineer", GitHub, Projects, Tech Skills), +but that's only because we needed *some* fixture for visual +regression. The same builders fit any persona — skip the link / section +types you don't need. + +```java +// A primary school teacher's CV — no GitHub, no Projects, no Tech Skills +CvDocument.builder() + .identity(CvIdentity.builder() + .name("Maria", "Lopez") + .contact("+34 600 000 000", "maria@example.com", "Madrid, Spain") + // no .link(...) calls — she has no public profiles, and that is fine + .build()) + .section(new ParagraphSection("About Me", + "Primary school teacher with 12 years' experience in literacy " + + "and inclusive education.")) + .section(EntriesSection.builder("Teaching Experience") + .entry("Lead Teacher Y3", "Colegio Santa Ana", "2018-Present", + "Year-3 lead teacher; designed the school's reading-rota " + + "and mentored two newly-qualified teachers.") + .entry("Year Teacher", "Escuela Primaria Goya", "2013-2018", + "Y1-Y2 generalist; led the SEN reading-club after hours.") + .build()) + .section(RowsSection.builder("Languages", RowStyle.PLAIN) + .row("Spanish", "Native") + .row("English", "Fluent (CEFR C1)") + .row("Catalan", "Conversational") + .build()) + .section(RowsSection.builder("Certifications", RowStyle.PLAIN) + .row("Inclusive Education", "Universidad Complutense, 2020") + .row("Children's First Aid", "Cruz Roja, 2022") + .build()) + .build(); +``` + +Notice what's absent: + +- **No `link(...)` calls.** Optional, simply omitted. +- **No Projects, no Skills.** The three section types + (`ParagraphSection`, `RowsSection`, `EntriesSection`) work for any + content shape — you choose what to put in them and what to call + them. +- **No required IT vocabulary anywhere.** Section titles are free + strings (`"About Me"`, `"Teaching Experience"`, `"Certifications"`). + +The rest of this guide leans on dev-style examples for continuity, +but every recipe below works the same way for any persona. + +--- + ## Recipe 1 — change a bullet glyph You want `▶` instead of `•`, or numbered bullets, or em-dashes. @@ -211,7 +265,52 @@ guard.) --- -## Recipe 6 — conditional sections (data-driven) +## Recipe 6 — place sections in slots (sidebar / footer) + +A `CvDocument` is not just a flat list of sections — every section is +**placed** into a `Slot` (one of `MAIN`, `SIDEBAR`, `FOOTER`). +Single-column presets like `BoxedSections` read only `Slot.MAIN` +sections; multi-column presets read whichever slots they support. + +**Placing a section into a slot** (builder API): + +```java +CvDocument doc = CvDocument.builder() + .identity(identity) + .section(summary) // defaults to MAIN + .section(Slot.MAIN, technicalSkills) // explicit, same as above + .section(Slot.SIDEBAR, languagesSpoken) // sidebar column + .section(Slot.SIDEBAR, certifications) // sidebar column + .sections(Slot.MAIN, experience, projects) // varargs in a slot + .build(); +``` + +**Reading by slot** (in a preset's `compose()`): + +```java +// Single-column preset — render only MAIN, drop the rest +List mainSections = doc.sectionsIn(Slot.MAIN); + +// Two-column preset +List mainSections = doc.sectionsIn(Slot.MAIN); +List sidebarSections = doc.sectionsIn(Slot.SIDEBAR); +``` + +**Why this matters:** when the user feeds your document to a +single-column preset, sidebar sections are silently dropped (they +have no place to render). When the same document goes through a +two-column preset, sidebar sections show up in the sidebar column. +The data model is the same — only the preset's interpretation +differs. + +**Tip:** `doc.sections()` returns *every* section in source order +regardless of slot. Use it for debug printing or unfiltered +iteration — not in a preset's render loop unless you really want +sidebar content to flow inline with main. + +--- + +## Recipe 7 — conditional sections (data-driven) Real CVs often hide a section when there's nothing to put in it: diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java index de294886..bd214778 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java @@ -6,27 +6,127 @@ /** * Root of the v2 CV data model — required {@link CvIdentity} block - * plus an ordered list of {@link CvSection} entries that render in - * source order beneath the header. + * plus an ordered list of section {@link Placement}s. * - *

This record carries no styling or rendering decision. Pair it - * with a - * {@link com.demcha.compose.document.templates.cv.v2.theme.CvTheme} - * and a preset from - * {@code com.demcha.compose.document.templates.cv.v2.presets} to - * produce a PDF.

+ *

Each {@link Placement} pairs a {@link CvSection} with a + * {@link Slot} so multi-column presets can decide which sections go + * into the sidebar vs. the main column without having to guess from + * section titles. Sections built without an explicit slot default to + * {@link Slot#MAIN}.

* - * @param identity required identity / contact block - * @param sections ordered sections; rendered in source order + *

Single-column presets read {@link #sectionsIn(Slot) + * sectionsIn(Slot.MAIN)} and ignore the rest; multi-column presets + * call {@code sectionsIn} once per slot they support.

+ * + * @param identity required identity / contact block + * @param placements ordered placements; render order matches source + * order within each slot */ -public record CvDocument(CvIdentity identity, List sections) { +public record CvDocument(CvIdentity identity, List placements) { public CvDocument { Objects.requireNonNull(identity, "identity"); - Objects.requireNonNull(sections, "sections"); - sections = List.copyOf(sections); + Objects.requireNonNull(placements, "placements"); + placements = List.copyOf(placements); + } + + /** + * Backward-compatible factory that wraps every supplied section + * in a {@link Slot#MAIN} placement. Provided so callers + * migrating from the pre-slot {@code new CvDocument(identity, + * sections)} pattern have a 1:1 replacement without rewriting + * their construction code. + * + *

Equivalent to building each section through + * {@link Builder#section(CvSection)} (which also defaults to + * {@link Slot#MAIN}). Prefer the {@link Builder} in new code — + * multi-column presets need explicit slot placement that the + * builder makes obvious at the call site.

+ * + * @deprecated since the slot model — prefer the {@link Builder} + * so non-MAIN slots are visible at the call site. + */ + @Deprecated + public static CvDocument ofMainSections(CvIdentity identity, + List sections) { + return new CvDocument(identity, toMainPlacements(sections)); + } + + /** + * One section together with the slot it should render in. + * + * @param slot placement region (never null) + * @param section section body (never null) + */ + public record Placement(Slot slot, CvSection section) { + + public Placement { + Objects.requireNonNull(slot, "slot"); + Objects.requireNonNull(section, "section"); + } + } + + // -- accessors ------------------------------------------------------- + + /** + * Returns every section in this document in source order, + * regardless of slot. Useful for debugging, indexing, or + * iterating all sections without caring where they render. + * + *

Presets should usually call {@link #sectionsIn(Slot)} + * instead — a single-column preset that iterates this flat list + * will accidentally render sidebar content inline with the main + * flow.

+ * + * @return flat list of all sections, source order + */ + public List sections() { + List out = new ArrayList<>(placements.size()); + for (Placement p : placements) { + out.add(p.section()); + } + return List.copyOf(out); + } + + /** + * Returns the sections placed in {@code slot}, in source order. + * The standard call from a preset's {@code compose()} method. + * + * @param slot non-null slot + * @return sections placed in that slot; empty list if none + */ + public List sectionsIn(Slot slot) { + Objects.requireNonNull(slot, "slot"); + List out = new ArrayList<>(); + for (Placement p : placements) { + if (p.slot() == slot) { + out.add(p.section()); + } + } + return List.copyOf(out); + } + + /** + * Returns the slot a given section was placed in. Uses identity + * comparison ({@code ==}) to avoid surprises when two sections + * happen to be record-equal but were added separately. + * + * @param section section instance to look up + * @return its slot, or {@link Slot#MAIN} if the section is not + * in this document + */ + public Slot slotOf(CvSection section) { + Objects.requireNonNull(section, "section"); + for (Placement p : placements) { + if (p.section() == section) { + return p.slot(); + } + } + return Slot.MAIN; } + // -- builder --------------------------------------------------------- + /** * @return new fluent builder */ @@ -35,12 +135,16 @@ public static Builder builder() { } /** - * Mutable builder. Section order in the builder is preserved 1:1 - * in the built document. + * Mutable builder for {@link CvDocument}. + * + *

The {@code section}-family methods append to the placement + * list in call order. Pass a {@link Slot} as the first argument + * to place into a non-main slot; omit it to default to + * {@link Slot#MAIN}.

*/ public static final class Builder { private CvIdentity identity; - private final List sections = new ArrayList<>(); + private final List placements = new ArrayList<>(); private Builder() { } @@ -50,37 +154,76 @@ public Builder identity(CvIdentity value) { return this; } + /** + * Appends one section into {@link Slot#MAIN}. + */ public Builder section(CvSection section) { - this.sections.add(Objects.requireNonNull(section, "section")); + return section(Slot.MAIN, section); + } + + /** + * Appends one section into a chosen slot. + */ + public Builder section(Slot slot, CvSection section) { + this.placements.add(new Placement(slot, section)); return this; } /** - * Varargs convenience for the common case where every section - * is constructed up-front. Equivalent to chained - * {@link #section(CvSection)} calls. - * - * @param values one or more sections in render order - * @return this builder + * Varargs convenience — all sections placed in + * {@link Slot#MAIN}. */ public Builder sections(CvSection... values) { Objects.requireNonNull(values, "values"); for (CvSection s : values) { - section(s); + section(Slot.MAIN, s); + } + return this; + } + + /** + * Varargs convenience — all supplied sections placed in + * {@code slot}. + */ + public Builder sections(Slot slot, CvSection... values) { + Objects.requireNonNull(slot, "slot"); + Objects.requireNonNull(values, "values"); + for (CvSection s : values) { + section(slot, s); } return this; } + /** + * List variant — all sections placed in {@link Slot#MAIN}. + */ public Builder sections(List values) { Objects.requireNonNull(values, "values"); for (CvSection s : values) { - section(s); + section(Slot.MAIN, s); } return this; } + /** + * Appends a pre-built {@link Placement}. + */ + public Builder placement(Placement placement) { + this.placements.add(Objects.requireNonNull(placement, "placement")); + return this; + } + public CvDocument build() { - return new CvDocument(identity, sections); + return new CvDocument(identity, placements); + } + } + + private static List toMainPlacements(List sections) { + Objects.requireNonNull(sections, "sections"); + List out = new ArrayList<>(sections.size()); + for (CvSection s : sections) { + out.add(new Placement(Slot.MAIN, s)); } + return out; } } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java new file mode 100644 index 00000000..da501d65 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java @@ -0,0 +1,34 @@ +package com.demcha.compose.document.templates.cv.v2.data; + +/** + * Logical placement region inside a {@link CvDocument}. Presets read + * the slot of each section to decide where on the page it should + * appear — main column, sidebar, or footer. + * + *

Single-column presets like + * {@code com.demcha.compose.document.templates.cv.v2.presets.BoxedSections} + * iterate only {@link #MAIN} sections; sections placed in + * {@link #SIDEBAR} or {@link #FOOTER} are silently dropped by such + * presets. Multi-column presets (two-column sidebar, magazine + * layouts) iterate {@link #MAIN} and {@link #SIDEBAR} separately to + * fill their columns.

+ * + *

Sections that don't specify a slot at build time default to + * {@link #MAIN} — so single-column callers keep working without + * changes after the slot model was introduced.

+ */ +public enum Slot { + + /** Primary content column. Default when no slot is specified. */ + MAIN, + + /** Sidebar / secondary column — typically narrower than MAIN. */ + SIDEBAR, + + /** + * Footer area below the main flow — useful for references, + * disclaimers, or page-bottom notes. Currently only rendered by + * presets that explicitly opt in. + */ + FOOTER +} diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java index 7d3fe544..8ee6da21 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java @@ -39,6 +39,17 @@ * and Professional Experience interchangeably. * * + *

Placement

+ * + *

Sections live inside a {@link com.demcha.compose.document.templates.cv.v2.data.CvDocument} + * as ordered {@link com.demcha.compose.document.templates.cv.v2.data.CvDocument.Placement} + * entries. Each placement pairs a section with a + * {@link com.demcha.compose.document.templates.cv.v2.data.Slot} + * ({@code MAIN}, {@code SIDEBAR}, {@code FOOTER}). Single-column + * presets read only {@code MAIN}; multi-column presets read multiple + * slots. Sections built without an explicit slot default to + * {@code MAIN}, so existing call sites stay valid.

+ * *

Adding a new section type

* *
    diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java index ce983f0a..1fc7d5a7 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java @@ -5,6 +5,14 @@ * documents on top of GraphCompose. Below is the conceptual model * every author should hold in their head before writing a template.

    * + *

    Persona-neutral. Nothing here assumes a + * software-developer audience. The shipped sample data is a + * developer CV because we needed a fixture for visual regression, + * but the same builders fit a teacher, a chef, a nurse, an + * artist — anyone. Skip the link / section types you don't need; + * section titles are free strings and links are optional. See + * {@code AUTHORS.md} for a non-IT example.

    + * *

    Four layers, one responsibility each

    * *
    @@ -119,6 +127,31 @@
      * }
      * }
    * + *

    Slots — placing sections in columns

    + * + *

    Every section is placed into a + * {@link com.demcha.compose.document.templates.cv.v2.data.Slot} — + * {@code MAIN}, {@code SIDEBAR}, or {@code FOOTER}. Single-column + * presets read only {@code MAIN} sections; multi-column presets + * read whichever slots they support, leaving the data model + * unchanged.

    + * + *
    {@code
    + * CvDocument doc = CvDocument.builder()
    + *     .identity(identity)
    + *     .section(summary)                     // implicit MAIN
    + *     .section(Slot.SIDEBAR, languages)     // sidebar column
    + *     .build();
    + * }
    + * + *

    Inside a preset:

    + * + *
    {@code
    + * for (CvSection s : doc.sectionsIn(Slot.MAIN)) {
    + *     // render s into the main flow
    + * }
    + * }
    + * *

    Going further — extension recipes

    * *

    See {@code AUTHORS.md} alongside this package for longer @@ -128,6 +161,7 @@ *

  1. Build a new theme variant (navy / compact / serif)
  2. *
  3. Write a brand-new preset that reuses existing renderers
  4. *
  5. Add a brand-new section subtype (compile-checked dispatch)
  6. + *
  7. Place sections in slots (sidebar / footer)
  8. * * *

    What this package is not

    diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java index 57d51364..33c1705f 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java @@ -9,6 +9,7 @@ import com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.v2.data.CvDocument; import com.demcha.compose.document.templates.cv.v2.data.CvSection; +import com.demcha.compose.document.templates.cv.v2.data.Slot; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; import java.util.List; @@ -114,7 +115,10 @@ public void compose(DocumentSession document, CvDocument doc) { ContactRenderer.render(section, doc.identity(), theme); }); - List sections = doc.sections(); + // Single-column preset — only renders MAIN-slot sections. + // Sidebar / footer placements are intentionally dropped here; + // switch to a multi-column preset to render them. + List sections = doc.sectionsIn(Slot.MAIN); for (int i = 0; i < sections.size(); i++) { final CvSection sec = sections.get(i); final int idx = i; diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java index 77dda81e..20c52adc 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java @@ -12,6 +12,7 @@ import com.demcha.compose.document.templates.cv.v2.components.TextOrnaments; import com.demcha.compose.document.templates.cv.v2.data.CvDocument; import com.demcha.compose.document.templates.cv.v2.data.CvSection; +import com.demcha.compose.document.templates.cv.v2.data.Slot; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; import java.util.List; @@ -108,7 +109,9 @@ public void compose(DocumentSession document, CvDocument doc) { ContactRenderer.render(section, doc.identity(), theme); }); - List sections = doc.sections(); + // Single-column preset — only renders MAIN-slot sections. + // Sidebar / footer placements are intentionally dropped here. + List sections = doc.sectionsIn(Slot.MAIN); for (int i = 0; i < sections.size(); i++) { final CvSection sec = sections.get(i); final int idx = i; diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java new file mode 100644 index 00000000..272e852f --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java @@ -0,0 +1,149 @@ +package com.demcha.compose.document.templates.cv.v2.data; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CvDocumentSlotTest { + + private static CvIdentity identity() { + return CvIdentity.builder() + .name("Jane", "Doe") + .contact("+44 0", "j@d.com", "London") + .build(); + } + + private static ParagraphSection summary() { + return new ParagraphSection("Summary", "body"); + } + + private static RowsSection skills() { + return RowsSection.builder("Skills", RowStyle.BULLETED) + .row("Languages", "Java").build(); + } + + @Test + void builder_section_defaults_to_main() { + ParagraphSection s = summary(); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(s) + .build(); + + assertThat(doc.placements()).hasSize(1); + assertThat(doc.placements().get(0).slot()).isEqualTo(Slot.MAIN); + assertThat(doc.slotOf(s)).isEqualTo(Slot.MAIN); + } + + @Test + void builder_section_with_explicit_slot_uses_it() { + ParagraphSection s = summary(); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(Slot.SIDEBAR, s) + .build(); + + assertThat(doc.placements().get(0).slot()).isEqualTo(Slot.SIDEBAR); + assertThat(doc.slotOf(s)).isEqualTo(Slot.SIDEBAR); + } + + @Test + void sections_returns_all_regardless_of_slot() { + ParagraphSection mainSec = summary(); + RowsSection sidebarSec = skills(); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(mainSec) + .section(Slot.SIDEBAR, sidebarSec) + .build(); + + assertThat(doc.sections()).containsExactly(mainSec, sidebarSec); + } + + @Test + void sectionsIn_filters_by_slot() { + ParagraphSection mainSec = summary(); + RowsSection sidebarSec = skills(); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(mainSec) + .section(Slot.SIDEBAR, sidebarSec) + .build(); + + assertThat(doc.sectionsIn(Slot.MAIN)).containsExactly(mainSec); + assertThat(doc.sectionsIn(Slot.SIDEBAR)).containsExactly(sidebarSec); + assertThat(doc.sectionsIn(Slot.FOOTER)).isEmpty(); + } + + @Test + void slotOf_returns_main_for_unknown_section() { + ParagraphSection orphan = new ParagraphSection("Orphan", "x"); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(summary()) + .build(); + + assertThat(doc.slotOf(orphan)).isEqualTo(Slot.MAIN); + } + + @Test + void sections_varargs_with_slot_places_all_in_that_slot() { + ParagraphSection a = new ParagraphSection("A", "x"); + ParagraphSection b = new ParagraphSection("B", "y"); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .sections(Slot.SIDEBAR, a, b) + .build(); + + assertThat(doc.sectionsIn(Slot.SIDEBAR)).containsExactly(a, b); + assertThat(doc.sectionsIn(Slot.MAIN)).isEmpty(); + } + + @Test + @SuppressWarnings("deprecation") + void deprecated_ofMainSections_wraps_everything_in_main() { + ParagraphSection a = new ParagraphSection("A", "x"); + ParagraphSection b = new ParagraphSection("B", "y"); + CvDocument doc = CvDocument.ofMainSections(identity(), List.of(a, b)); + + assertThat(doc.sectionsIn(Slot.MAIN)).containsExactly(a, b); + assertThat(doc.sectionsIn(Slot.SIDEBAR)).isEmpty(); + } + + @Test + void placements_preserves_source_order_across_slots() { + ParagraphSection a = new ParagraphSection("A", "x"); + RowsSection b = skills(); + ParagraphSection c = new ParagraphSection("C", "z"); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(a) + .section(Slot.SIDEBAR, b) + .section(c) + .build(); + + // sections() preserves the global add order + assertThat(doc.sections()).containsExactly(a, b, c); + // sectionsIn filters but keeps relative order + assertThat(doc.sectionsIn(Slot.MAIN)).containsExactly(a, c); + } + + @Test + void rejects_null_section() { + CvDocument.Builder b = CvDocument.builder().identity(identity()); + assertThatThrownBy(() -> b.section(null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> b.section(Slot.SIDEBAR, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rejects_null_slot() { + CvDocument.Builder b = CvDocument.builder().identity(identity()); + assertThatThrownBy(() -> b.section(null, summary())) + .isInstanceOf(NullPointerException.class); + } +}