Skip to content

feat(templates): route CV sections by what they mean, not by what they are called - #582

Merged
DemchaAV merged 5 commits into
feature/cv-constructor-layerfrom
feat/cv-role-routing
Aug 20, 2026
Merged

feat(templates): route CV sections by what they mean, not by what they are called#582
DemchaAV merged 5 commits into
feature/cv-constructor-layerfrom
feat/cv-role-routing

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Stacked on #581#580#579, all targeting feature/cv-constructor-layer at the bottom. Merge in order; that branch is the snapshot to try the approach on before anything reaches develop.

Why

A preset with a designed layout places sections into fixed slots, and it chose what went where by matching the section's heading against a list of English words each preset kept privately — then guarded the slot on the section's Java type as well. A CV headed Ausbildung, Опыт работы, or anything else in the author's own language matched nothing: the section was dropped and the slot that wanted it rendered empty. Nothing failed; the CV came out looking finished, one job short. That is the failure the whole runtime-module model exists to remove, and until now it removed it only for the presets that render every section in order.

A module already knows the answer, because its author said so when they picked the role.

What changed

  • SectionRouter asks the role first, and falls back to the headings for the sections that carry none — every hand-written one, and any module left as SectionRole.OTHER — so a document of hand-written sections routes exactly as it did.
  • A heading may not overrule a role. A module declared EXPERIENCE and headed "Projects" goes where its author put it; without this the experience slot claims it by role and the projects slot claims it by heading, and it renders twice. An empty module also no longer shadows a section that has content.
  • Each slot receives the section in the shape it draws. These slots are written against one section type because each draws its content its own way, so a correctly-routed module was still discarded by if (!(section instanceof EntriesSection …)) return;. The router lowers a matched module to the type the slot expects, and the preset then draws it with the entry style, rules and spacing that make it that preset — rather than the canonical components, which several of these layouts cannot even host (a nested row inside a card throws).
  • What the lowering costs is stated per finder and pinned by tests: a description is joined where the target type holds one string; bulleted points join with a comma and prose with a space (joined the other way, "Rebuilt the ledger Cut p99 40%" reads as one garbled sentence and "Shipped it., Measured it." puts a comma after a full stop); a linked title survives only the row style that bolds through the text style rather than markdown markers; and a skill with nothing under it stays a skill instead of becoming a category holding itself (Java 21: Java 21).
  • SectionAllocation.claim(role, keys) is the same idea for the preset that allocates rather than looks up, and TimelineMinimal learned to flatten a module the way it flattens everything else — one line per item, because those lines are what its column pagination measures; rendering it richly would decouple the draw from the height estimate and overflow an atomic row.
  • SidebarPortrait's languages slot sniffs its rows for something language-shaped, because it also accepts an Additional Information section and has to pick them out of it. A section routed there by role is entirely languages and needs to look like nothing, so when the sniff finds none it now takes them all — otherwise role routing would have replaced a silent drop with a heading over blank space, which is worse.

Verification

  • Full reactor gate → BUILD SUCCESS, qa 859, templates 135 (+12). japicmp (both baselines, both modules) clean; javadoc:javadoc → 0 warnings. The 16 visual baselines are untouched, which follows from the fallback being the old lookup verbatim for sections that carry no role.
  • RoleRoutingTest: a CV written in Russian and German rendered through all sixteen presets, asserting the experience and education modules reach the page — plus the "wrong" kind for each slot (experience as bullets, education as an inline list), because the author picks the kind and the preset gets no veto. Proven non-vacuous: disabling role lookup turns 17 of its 34 cases red.
  • SectionRouterTest (12 cases) pins each lowering's output strings — the separator, the linked title, the skills grouping, the period suppression, and that a section already of the right type passes through untouched rather than being rebuilt.
  • SectionAllocationTest (+4) covers the role-first overload: role beats heading, heading still works for role-less sections, a declared role is not claimable by another slot's keywords, and claiming still hands each section out once.

Lane: templates (two components, ten presets).

Limits:

  • These presets still drop a section they have no slot for — an extra "Volunteering" module is lost on all ten. The cause is structural: the whole body of each is one flow.addRow(...), which is atomic and cannot break across pages, so there is nowhere to put an extra section. Lifting it is the pagination work (ColumnPagination), not routing, and the *_LIMIT caps that exist because of it come off in the same change.
  • None of these presets becomes ModularCvTemplate here, for exactly that reason; CvTemplates.modular() still lists seven.
  • The RowStyle a caller passes to SectionRouter.rows reaches the page only in Panel; the other slots draw rows their own way and ignore it.

The post-release bump always opens the next patch, so the train sat on
2.2.1-SNAPSHOT. The next release adds public API to graph-compose-templates,
which makes it a minor — and an @SInCE tag written while the poms and the
CHANGELOG name different lines has two answers to choose between, one of
which outlives the cycle.

The CHANGELOG heading records the real next version and the thirteen train
poms follow it, which is the order VersionConsistencyGuardTest pins. The
README development line and the roadmap's note of when the templates gate
ships move with them. The install snippets stay on 2.2.0 — the version
actually on Central.
The four CvSection records each fix one shape when the code is written,
which is right for a CV written in Java: you pick the record and the
compiler checks it. It is the wrong model when the CV arrives as data. A
user who has just chosen "Volunteering, shaped like Education, with
dates" cannot instantiate a different record per choice, so every shape
anyone thought of would have to become a type — and the shapes nobody
thought of stay impossible.

ModuleSection carries the choice as a value instead. One CvItem holds
every optional field — title, link, subtitle, period, location,
description — and a CvKind decides which of them are read: the same item
renders with or without its dates depending on the kind alone. BodyStyle
decides whether a description reads as prose or as bullets. SectionRole
states what a section means, separately from how it draws, which is the
input the sidebar routing needs and cannot get from an English keyword
list; no preset reads it yet.

ModuleRenderer lowers each kind onto the renderers this package already
ships rather than drawing anything itself, so a module and the
hand-written section carrying the same content are two spellings of one
document. ModuleSectionParityTest holds that node-for-node — layout
snapshot for structure, extracted text for content — for every kind.

Inline versus stacked bullets is the module's choice (BULLETS,
BULLETS_STACKED) rather than something inferred from how long a
description happens to be: a section reads one way throughout, and an
author who asked for descriptions underneath gets them whether the first
entry is one line or five.

Three ways a section shape a preset did not recognise used to be lost
are closed with it: BlueBanner and ClassicSerif threw from a private
copy of the dispatcher, EditorialBlue's had no final branch, and
SectionLookup.hasContent answered false for any subtype it had not been
taught — discarding the heading with the body, through the very fallback
that exists to place unclaimed sections. An entry with no date also stops
reserving an empty column for one, which its Javadoc has described since
it was written; no shipped fixture has a blank date, so no existing
render moves.
…able

The list copy was pinned from one side only — a caller's list cannot reach
into a built module. The other side, that the accessor hands out nothing a
caller can mutate, was left to List.copyOf's reputation.
A module is only worth building if the template renders it, and not every
preset can promise that. Several compose a fixed set of modules and find
each by matching headings, so a section they do not recognise never
reaches a renderer: the CV comes out, minus a section, looking finished.
Nothing about that failure is visible at the point it happens.

ModularCvTemplate is the promise and CvTemplates.modular() is the list a
CV builder should offer; CvTemplates also answers byId, all, ids, and
recommendedMargin, so picking a preset at runtime stops being a map kept
by hand in every consumer. Declaring the interface costs something:
ModularCvTemplateFidelityTest renders every kind, a section this
catalogue has no name for, a heading in a script no keyword list
contains, and a heading that does match one, through each template that
declares it. Seven presets qualify. ClassicSerif does not — it draws any
shape it is given but only gives itself the sections it recognises, and
finding that out is what the gate is for.

The promise covers Slot.MAIN and says so, rather than leaving "renders
whatever it is handed" to be read generously: every shipped preset
composes one main column, so a sidebar section is dropped by these
templates as by every other. The gate pins that too, so the contract and
the code have to change together.

CvRenderKit is the three shapes a section body reduces to — a paragraph,
a label/value row, a timeline entry — and a template hands back the kit
it draws them with. The lowering from CvItem stays shared: what a linked
title looks like, which fields a kind reads, what an empty description
does to a trailing colon are the model's decisions and must not be
re-made sixteen times. BlueBanner, ClassicSerif and EditorialBlue now
draw modules with their own entry and project shapes.

EditorialBlue also stops renaming a module's heading. Its keyword
vocabulary turned "Certifications & Awards" into EDUCATION, which is the
one thing the promise says cannot happen; the canonical sections keep the
rename that gives the preset its voice.

CvTemplatesCoverageTest derives the catalogue from the presets package
rather than trusting it, so a preset that ships unregistered fails the
build instead of being invisible to every caller that looks one up by id.
…y are called

A preset with a designed layout places sections into fixed slots, and it
chose what went where by matching the section's heading against a list of
English words each preset kept privately — then guarded the slot on the
section's Java type as well. A CV headed Ausbildung, Опыт работы, or
anything else in the author's own language matched nothing: the section
was dropped and the slot that wanted it rendered empty. Nothing failed.
The CV came out looking finished, one job short.

A module already knows the answer, because its author said so.
SectionRouter asks the role first and falls back to the headings for the
sections that carry none — every hand-written one, and any module left as
OTHER — so a document of hand-written sections routes exactly as before. A
heading may not overrule a role: a module declared EXPERIENCE and headed
"Projects" goes where its author put it, and the projects slot does not
also claim it, which would have rendered it twice. An empty module does
not shadow a section that has content.

The second half is the shape. These slots are written against one section
type because each draws its content its own way, so a module reaching one
was discarded by the guard however well it was routed. The router hands
each slot the section lowered to the type it expects, and the preset then
draws it with the entry style, rules and spacing that make it that preset.
What the lowering costs is stated per finder and pinned by tests: a
description is joined where the target holds one string, bulleted points
join with a comma and prose with a space, a linked title survives only the
row style that bolds without markdown markers, and a skill with nothing
under it stays a skill rather than becoming a category holding itself.

SectionAllocation.claim gained the same role-first overload for the preset
that allocates rather than looks up, and TimelineMinimal learned to
flatten a module the way it flattens everything else — one line per item,
because those lines are what its column pagination measures.

SidebarPortrait's languages slot sniffs its rows for something
language-shaped, because it also accepts an Additional Information section
and has to pick them out. A section routed there by role is entirely
languages and needs to look like nothing, so when the sniff finds none it
now takes them all; otherwise role routing would have replaced a silent
drop with a heading over blank space.

These presets still drop a section they have no slot for. Their whole body
is one atomic row that cannot break across pages, so there is nowhere to
put it — that half waits on pagination, not routing.
@DemchaAV
DemchaAV changed the base branch from feat/cv-modular-templates to feature/cv-constructor-layer August 20, 2026 20:59
@DemchaAV
DemchaAV merged commit 8f6e1db into feature/cv-constructor-layer Aug 20, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feat/cv-role-routing branch August 20, 2026 21:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant