feat(templates): route CV sections by what they mean, not by what they are called - #582
Merged
Merged
Conversation
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
changed the base branch from
feat/cv-modular-templates
to
feature/cv-constructor-layer
August 20, 2026 20:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
SectionRouterasks the role first, and falls back to the headings for the sections that carry none — every hand-written one, and any module left asSectionRole.OTHER— so a document of hand-written sections routes exactly as it did.EXPERIENCEand 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.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).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.Verification
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:
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*_LIMITcaps that exist because of it come off in the same change.ModularCvTemplatehere, for exactly that reason;CvTemplates.modular()still lists seven.RowStylea caller passes toSectionRouter.rowsreaches the page only inPanel; the other slots draw rows their own way and ignore it.