Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
c975l-book-display-pages
c975l-book-display-pages
c975l/book-bundle
claude-code
symfony
bundle
book
serie

Use this skill when working on the public pages of a catalog built on the c975L BookBundle — the page of a book, of a serie, of a planche and of a person the catalog credits, their hero and the row of buttons it offers, their sections and the order they come in, the cards a listing prints, the breadcrumb and the arrows a planche is browsed with, the editor's pencil floating over a section, and the CSS tokens a site retunes them all with. Triggers on: book_display, BookSettings, BookSettingsCrudController, BookSettingsRepository, render_owned_blocks, Book:Explanation, serie_display, strip_display, book_index, serie_index, strip_index, contributor_display, contributor_index, book_sections, serie_sections, book_section_template, BookSectionsExtension, Book:Hero, Serie:Hero, Strip:Hero, Book:Resume, Book:Pages, Book:Podcasts, book_listen, book_listen_index, book_listen_json, book-route-listen, ListenController, ListenJsonController, LISTEN_CONDITION, withAudio, cues, cuesFile, WebVTT, slider:changed, label.listen_with_pages, label.listen_download, Pwa:Download, ui_pwa_downloads, offline, Book:Videos, Book:Shops, Book:Informations, Book:Card, Book:Flipbook, Book:Crowdfunding, Contributor:Hero, Contributor:Shops, Contributor:Contributor, Contributor:Contributors, Strip:Card, Strip:Cards, Serie:Characters, Strip:Medias, Strip:Summary, StripMediaKind, getMediasByKind, getThumbnailMedia, getShareMedia, strip_highres, BookHighresExtension, Image:Zoom, Book:Books, Serie:Series, infinite, infiniteScroll, displayMore, label.books_more, label.series_more, load_more, Pagination, Paginator, priority, fetchpriority, Strip:Characters, Strip:Previous, Strip:Next, Breadcrumb, _section.html.twig, toc-target, card--compact, book-page, book-hero, strip-card, book_edit_url, book_edit_urls, serie_edit_urls, strip_edit_urls, BookEditUrlExtension, book_cover, book_back_cover, book_media, book_medias_of_kind, book_audio_medias, book_translations, book_language_label, languageName, Languages, book_role_label, getRoles, book_alternates, book_versions, contributor_path, contributor_url, contributor_edit_url, contributor_edit_urls, BookUrlExtension, BookTranslationExtension, BookVersionExtension, book_plays, BookPlaysExtension, book_sections_cache_ttl, book_sections_cacheable, BookSectionsCacheExtension, _sections.html.twig, book-plays-minimum, book-listen-data, ui-play-count, ui_play, ui_play_start, PlayTicket, BookPlayDurationProvider, PlayDurationProviderInterface, ui-play-ratio, playStart, ofMedia, PlayService, withCreditsAndVideos, isFree, label.get_free, book-strip-card, book-confetti, book-rating, book-page-sound, pageSound, page-turn.mp3, sass/_variables.scss, themes/book.css, block-thumbs, ui-block-thumb, ui.management_stylesheet, getManagementStylesheets, BundleStylesheetManagementProviderInterface, StylesheetProvider, BookBlockCacheTagProvider, BookBlockCacheInvalidator, BookCacheInvalidationListener, book_ui_locale, BookTranslator, apply, BookTranslatedLocales, forStrip, BookPublicUrlResolver, resolveLocalizedPath, resolveAlternates, BookLinkLocalizer, _localized, findCharactersBySerie, book_catalog, LinkableRouteCacheTagsInterface, BookCollectionSourceProvider, hasScheduled, cacheable, book_reader, ReaderBlockType, ReaderCueType, cues, linkLabel, linkUrl, AbstractBookListingBlockType, BookSnippetBuilder, buildVideos, VideoObject, uploadDate, c975l:book:video-upload-dates, BookVideoUploadDatesCommand, BookJsonLdExtension, book_json_ld, serie_json_ld, strip_json_ld, contributor_json_ld, book_breadcrumb_json_ld, level, book-card__title, --book-card-title-size, book_item_list_json_ld, BreadcrumbList, ItemList, Person, ProfilePage, withRating, aggregateRating, OnixController, book_onix, BookOnixBuilder, outOfPrint, AudioDurationReader, BookSubjectScheme, ONIX, book_google, book-route-google, GooglePlayFeedController, GooglePlayFeed, Google Play Books.

Install
php artisan boost:add-skill c975l/book-bundle

Save this content to: .claude/skills/c975l-book-display-pages/SKILL.md

# c975L BookBundle — display pages

> A page is its hero, then a column of sections. Which sections there are is answered once, by `book_sections()` / `serie_sections()`, and read twice — by the hero, to offer the ones a reader comes for, and to render the page — so a section is never offered by a button without being on the page.

**Package:** `c975l/book-bundle` · **Bundle:** `c975L\BookBundle\` · **Twig namespace:** `@c975LBook` · **Translation domain:** `book`

**Key source paths** (relative to the package root):
`src/Controller/OnixController.php`, `src/Controller/ListenJsonController.php`, `src/Controller/GooglePlayFeedController.php`, `src/Service/GooglePlayFeed.php`, `src/Service/BookOnixBuilder.php`, `src/Service/AudioDurationReader.php`, `src/Service/BookPlayDurationProvider.php`, `src/Twig/BookSectionsExtension.php`, `src/Twig/BookEditUrlExtension.php`, `src/Twig/BookUrlExtension.php`, `src/Twig/BookTranslationExtension.php`, `src/Twig/BookVersionExtension.php`, `src/Service/BookPublicUrlResolver.php`, `src/Service/BookTranslator.php`, `src/Service/BookTranslatedLocales.php`, `src/Service/BookLinkLocalizer.php`, `src/Controller/BookController.php`, `src/Entity/BookSettings.php`, `src/Controller/Management/BookSettingsCrudController.php`, `src/Controller/SerieController.php`, `src/Controller/StripController.php`, `src/Controller/ContributorController.php`, `templates/book/display.html.twig`, `templates/book/_section.html.twig`, `templates/book/index.html.twig`, `templates/serie/display.html.twig`, `templates/serie/index.html.twig`, `templates/strip/display.html.twig`, `templates/strip/index.html.twig`, `templates/contributor/display.html.twig`, `templates/contributor/index.html.twig`, `templates/components/`, `templates/blocks/`, `src/Enum/StripMediaKind.php`, `src/Twig/BookHighresExtension.php`, `src/Form/Block/`, `src/Service/BookBlockCacheTagProvider.php`, `sass/_variables.scss`, `sass/_book.scss`, `sass/block-thumbs.scss`, `src/Service/StylesheetProvider.php`, `scaffold/assets/styles/themes/book.css`, `src/Command/BookVideoUploadDatesCommand.php`

**Related skills:** `c975l-book-customization` and `c975l-book-lifecycle` in this same bundle, and `c975l-blocks`, `c975l-media`, `c975l-ui-assets` in UiBundle beside it.

## The pages

| Route | URL | Answers |
| --- | --- | --- |
| `book_index` | `/livres` | the catalog, growing on scroll, under the line and the blocks composed on the *Books page* screen (`BookSettings`, a single row) |
| `book_display` | `/livre/{slug}` | one book — 410 for a trashed one, 301 from its number |
| `serie_index` | `/series` | the series telling books, growing on scroll |
| `serie_display` | `/series/{slug}` or `/strips/{slug}` | one serie, under the index listing it |
| `book_category_index` | *unset by default* | the categories holding a shown book, growing on scroll |
| `book_category_display` | *unset by default* | one category — the books filed under it |
| `contributor_index` | `/auteurs` | the people the catalog credits, growing on scroll |
| `contributor_display` | `/auteur/{slug}` | one person — what they signed, books then series |
| `strip_index` | `/strips` | the series telling planches, not the planches, growing on scroll |
| `strip_display` | `/strip/{slug}` | one planche |
| `book_shortcut` / `strip_shortcut` | `/b3`, `/s3` | 301 to the slug |
| `book_listen_index` | *unset by default* | the books holding a recording, in the language being read |
| `book_listen` | *unset by default* | one book played — its pages turning along the voice when it carries a `cues` file |
| `book_listen_json` | *unset by default* | the listening pages as JSON, at the listening prefix followed by `.json` (`ListenJsonController`) |
| `book_onix` | *unset by default* | the catalog as an ONIX 3.0 feed for the stores — one product per edition holding an ISBN-13 and ticked "ONIX", an edition ticked "Out of print" announced so (`OnixController`, `BookOnixBuilder`) |
| `book_google` and below | *unset by default* | the folders Google Play Books fetches, behind HTTP Basic — the ebooks and audiobooks ticked "Google" — an audiobook only once it has its own square cover —, their ONIX, files and JPEG covers (`GooglePlayFeedController`, `GooglePlayFeed`) |

Every prefix is a ConfigBundle setting (`book-route-books`, `book-route-book`, `book-route-series`,
`book-route-categories`, `book-route-contributors`, `book-route-contributor`, `book-route-strips`,
`book-route-strip`, `book-route-book-shortcut`, `book-route-strip-shortcut`, `book-route-listen`, `book-route-onix`) — see
`c975l-book-customization`. **A prefix emptied means the site serves that page itself**, and the route
answers nothing: anything linking to it goes through `BookPublicUrlResolver::resolveLocalizedPath()`, which
returns null rather than throwing. `book_path()`, `serie_path()`, `serie_url()`, `contributor_path()`,
`contributor_url()` and `book_category_path()` already do.

**Every one of these pages answers in each language the site declares too.** Its localised twin —
`book_display_localized` beside `book_display`, `serie_index_localized` beside `serie_index`, and so on — answers
`/{_locale}/…` behind the very same condition, whether or not the row has been translated yet: "/en" is the
language the catalog is read in (`BookTranslatedLocales`). **A planche is the exception**: its text is the whole of
it, so it answers only in the languages it was written in (`forStrip()`), and its url in any other 301s to the bare
one rather than answering 404 — the sitemap and its hreflang group name those languages alone. The bare url stays the writing language's own. A link
goes through `book_path()` and its siblings, which answer in the language being read — `path('book_display', …)`
sends a visitor reading "/en" back into the writing language at the first click. `BookPublicUrlResolver::resolvePath()`
stays the canonical url, the one a sitemap, a hreflang group and a JSON-LD node declare.

**`book-route-categories` and `book-route-listen` are the two prefixes shipping empty**: a site serves no category page until it says
under which word it wants them, and the categories a book carries still print on its sheet as plain words —
which is a site using them as tags rather than as rayons. The listening pages are a site's choice the same way.

**The listening pages (`ListenController`) play a book by its `cues` media**, a WebVTT file uploaded on the
"Page timecodes" field — one cue per page, its identifier the page's number. A book carrying one is handed to
`<twig:c975LBook:Reader:Reader cuesFile="{{ book.cues }}">` with `book.pages` (a sound rings at each page the voice reaches
when `book-page-sound` is on, `pageSound` naming another file); a book without one is shown as its
cover and the player, a few pages to leaf through before buying being no book to follow along. A book with no
recording the player can read — `book_audio_medias()`, an `audio/*` file: mp3, ogg, m4a or wav — answers 404
there and stays off the index, whatever kind its files were filed under; so does a book still to come
(`isToBePublished()`). `reader.js` follows UiBundle's
`slider:changed` event, so a page turned by arrow, tap or swipe moves the recording too. `book_listen` also mounts UiBundle's
`<twig:c975LUi:Pwa:Download>` with every file the player needs offline — the recording, the cover, and with `cues` the pages,
the timecodes and the page-turn sound — and `book_listen_index` links to `ui_pwa_downloads` when `pwa_enabled()`.

**`book_listen_json` is the same list as data, for a reader outside the site** (`ListenJsonController`): one answer holding
`pageSound` and `books`, each with its `id`, `slug`, `title`, `summary`, `language`, `modification`, `cover`, `coverBack` (the fourth cover, another version's for want of one as `book_back_cover()` reads it, null without), `coverHighres`/`coverBackHighres`/`pagesHighres` (the `-highres.webp` beside each, null where none is on disk), `audio`
(`url`, `type` — the mp3 first), `pages`, `cues`, `published`, `age`, `serie` (its title translated, `number`), `credits` (`role`, `name`, the roles named in `book_ui_locale()`), `videos` (`title`, `youtube`, `url`), `plays` (null below `book-plays-minimum`, as on the pages: `BookPlaysExtension::shown()`), `playStart` and `play` (the absolute `ui_play_start` and `ui_play` urls an app takes its ticket from and hands it back to, as the listening page does), `data` (the fields of `Book::$data` the site lists in `book-listen-data`, none by default) and `page` (the book's own page in the asked language, null where the site serves none). Every url is absolute, asked to VichUploader then to `UrlHelper`. `pages`
and `cues` come together or not at all: a book with no timecodes, or with a `page` or `cues` row holding no file, answers
`pages: []` and `cues: null`. The answer carries a weak ETag and stays `private`. Its routes sit behind
`BookRoutePrefix::LISTEN_CONDITION`, the one condition of every listening route, and list the books
`BookSectionsExtension::withAudio()` keeps. Credits and videos are loaded for the whole list by `BookRepository::withCreditsAndVideos()`.

**A story heard far enough counts one listen**: `book_listen` wraps its player in UiBundle's `ui-play-count` controller, taking a ticket from `ui_play_start` and posting it to `ui_play` under `BookFavoriteItemProvider::OWNER_TYPE` once `ui-play-ratio` of it is reached; `BookPlayDurationProvider` tells UiBundle how long the recording lasts (the mp3, read by `AudioDurationReader::ofMedia()`), so a story jumped to its end counts nothing; a book with no readable mp3 counts on the ticket alone. `book_plays(book)` (`BookPlaysExtension`) reads the total, site and apps added up, and answers null below `book-plays-minimum`; a book's page prints it above its sections, out of their cache, and `book_listen` under its player.

`serie_display` is one route for both families: a serie's own `SerieKind` decides which index a serie
sits under, and one asked under the other segment answers a 301 to its own.

## The skeleton of a page

```twig
{# what the page holds, answered once #}
{% set sections = book_sections(book) %}

<twig:c975LBook:Breadcrumb .../>     {# where the page sits, and the way back out #}
<twig:c975LBook:Book:Hero book="{{ book }}" .../>   {# the page's only h1 #}
<twig:c975LBook:Book:Resume book="{{ book }}"/>     {# no title, no anchor: not a destination #}
{% for section in sections %}…{% endfor %}
```

**The hero carries the only `h1`** — the layout's own title block is left empty by the three display
templates, which render `Book:Hero`, `Serie:Hero` or `Strip:Hero` inside the container instead. A second
`h1` anywhere on these pages is a bug.

**The summary is no section.** It is the sentence the page opens on, printed by `Book:Resume` /
`Serie:Resume` under the hero and outside the sections grid, with no title and no anchor. Neither a book's
page nor a serie's carries a summary of anchors any more: the hero's row of buttons opens what a reader
looks for — `pages`, `podcasts`, `apercu`, `shops`, `presse`, `marketing`, in the order the page lays
them out, and only those it actually holds —
and the page keeps its whole width. `sass/_book.scss` opens the two-column layout on
`.book-page:has(> .book-page__toc)` alone — a page with no toc that grows one gets two columns back.

## The sections

| Owner | Keys, in the bundle's order |
| --- | --- |
| a book | `pages`, `podcasts`, `apercu`, `crowdfunding`, `shops`, `presse`, `marketing`, `informations` |
| a serie | `characters`, `books`, `strips` |

A section is present only when the book actually holds something for it, `informations` excepted — a book
with no field filled in still says when it comes out and who wrote it. A serie's `characters` asks more than
that: it is written for the serie whose people carry a presentation or a portrait, a bare list of names being
the row of chips a planche already prints. Each entry carries `key`, `anchor`,
`label` and, when the catalog holds one, `intro`. **`key` is the name everything else uses** — the site's
declaration, the pencil's map of fields; `anchor` is only what the page writes as an `id`, and a site may
rename it. Reading a section off its anchor is what silently loses a button when a site renames one.

Every section is rendered through `templates/book/_section.html.twig`, which owns the anchor, the name,
the icon, the entrance effect, the site's own fragment and the pencil — a section component writes nothing
but what goes inside:

```twig
{% set body %}…{% endset %}
{{ include('@c975LBook/book/_section.html.twig', {book: book, section: section, editUrl: editUrl, body: body}) }}
```

The contents arrive as an already-rendered `body` string and **never as a block an `{% embed %}` overrides**:
a block declared by an embed never reaches the inside of a component's slot, and the card came out titled
and empty, which neither the Twig lint nor a template test can see.

`form` decides the shape, not the contents: `text` and `framed` are written as a plain `<section>` (prose
carries no frame, so those two do the anchor, the title and the pencil themselves), anything else is a
`c975LUi:Card:Card`. Every section wears UiBundle's `toc-target`.

**A book's sections are cached** (`book/_sections.html.twig`, `{% cache %}` tagged `book_catalog` and `blocks_all`): one entry per book and language, living until midnight (`book_sections_cache_ttl()`), dropped by `BookCacheInvalidationListener` on anything a section prints, its `book_*` translations included, and on any `book-*` setting — once per flush. Rendered live for an editor and when `book_sections_cacheable(sections)` is false — a section declaring `cache: false`, or carrying a `template` without `cache: true`. Nothing per visitor goes inside it.

## The components a site overrides

They all live in `templates/components/` and are overridden in
`templates/bundles/c975LBookBundle/components/`. The ones a site touches most:

| Component | Prints |
| --- | --- |
| `<twig:c975LBook:Book:Hero>` | the opening — cover (flipped over on a click given a fourth), backdrop, eyebrow, title, a button per section reached from there |
| `<twig:c975LBook:Book:Resume>` | the sentence under the hero |
| `<twig:c975LBook:Book:Pages>` | the pages, in UiBundle's slider |
| `<twig:c975LBook:Book:Flipbook>` | the video turning them, under the hero and outside any card |
| `<twig:c975LBook:Book:Podcasts>` | one card for everything the book is listened to by, plus a link to `book_listen` when the book carries timecodes and pages. While `isToBePublished()`: the podcast apps alone, no player and no download, and the section goes when no link is left |
| `<twig:c975LBook:Book:Videos>` | one card for everything it is watched by |
| `<twig:c975LBook:Book:Shops>` | the bookshops, the site's own shop first and in the site colour — titled "Get it free" rather than "Buy" when `Book::isFree()` (a digital edition whose public price is 0) |
| `<twig:c975LBook:Book:Informations>` | the framed civil status — author, illustrator, everyone else the book credits, serie, editions, languages, versions |
| `<twig:c975LBook:Book:Card>` | a book in a listing: cover in portrait, summary, language — `:compact="true"` prints it at a thumbnail's width, without its summary |
| `<twig:c975LBook:Contributor:Hero>` | a person's opening — portrait, backdrop, name, what they are credited under (`Contributor::getRoles()`, which counts only what the catalog still shows), the sentences they are introduced with |
| `<twig:c975LBook:Contributor:Shops>` | where their books are bought: the same card and the same tiles as a book's, under `#shops` |
| `<twig:c975LBook:Contributor:Contributor>` | a person in a listing: portrait squared, name, what they are credited under, the whole card leading to their page |
| `<twig:c975LBook:Strip:Card>` | a planche, the whole card being the link |
| `<twig:c975LBook:Strip:Characters>` | who speaks, as chips narrowing the serie's listing — the face each wears is the one uploaded on their own screen |
| `<twig:c975LBook:Serie:Characters>` | who peoples the serie, presented above what it holds, parted into rows by `Character::$groupName` — drawn only where a character carries a presentation or a portrait |
| `<twig:c975LBook:Strip:Medias>` | what a planche shows, each media placed by its role — takes `editUrl`, `casesEditUrl` and `pageEditUrl`, one per collection |
| `<twig:c975LBook:Strip:Previous>` / `<twig:c975LBook:Strip:Next>` | the arrows laid over the planche |
| `<twig:c975LBook:Breadcrumb>` | the trail, for all three pages |

**A component overridden must pass its props through.** `Book:Videos`, `Book:Presses` and
`Book:Marketings` take the `book` itself rather than the collection, so they can print their title in the
book's language; `Book:Book` and `Book:Books` take `editable` and hand `editUrl` down to the card, which
is what keeps the pencil. Dropping either leaves a right page with a wrong language or no pencil at all.

## The language a page is read in

A row's own texts — a title, a summary, a character's name, a picture's title — are laid over it by
`BookTranslator::apply()` just before the render, in the language being read, and never persisted. The
controllers call it on everything their page prints, the serie a book belongs to and the neighbours of a planche
included. **Anything else handing a row to a template calls it first** — a site's own controller, a block
resolver, a provider copying a title into an item — or prints the writing language's text among labels already
turned.

The labels this bundle prints read `book_ui_locale(book.language)` rather than `book.language`: the language the
url reads where there is one, the row's own where there is none, which is every url of a single-language site.
The book's own language stays a fact about the book, printed by `book_language_label()` in the request's locale (`BookTranslationExtension::languageName()` for another one). `Strip:Characters`
receives `Character` entities (`StripServiceInterface::findCharactersBySerie()`), so their names follow too.

A catalog link an editor stored in a block — a card, a call to action, a word linked in a rich text — is
rewritten the same way by `BookLinkLocalizer`, its patterns built from the configured prefixes at each call.

## A listing that grows

The three index pages grow as the visitor scrolls instead of turning pages: UiBundle's `infiniteScroll`
controller fetches the page the listing's own "next" link points at and appends the cards found there.
Nothing is written for it alone — that link is an ordinary `rel="next"` link to the next page, which is
what a crawler follows and what happens without javascript or after a failed fetch.

```twig
<div id="books" data-controller="infiniteScroll">
    <twig:c975LBook:Book:Books books="{{ books }}" :infinite="true"/>
    <p><span data-infiniteScroll-target="count">{{ books|length }}</span> / {{ books.getTotalItemCount }}</p>
    <a href="{{ path(books.route, books.query({'p': books.getCurrentPageNumber + 1})) }}#books"
       rel="next" data-infiniteScroll-target="next" data-action="click->infiniteScroll#load">…</a>
</div>
```

`infinite` is what marks the grid the cards land in (`data-infiniteScroll-target="list"`), and it is the
attribute the controller reads the fetched page through: **only the listing that grows may carry it** —
a search's results sitting on the same page would otherwise answer in its place. `Book:Books`,
`Serie:Series`, `Contributor:Contributors` and `Strip:Cards` take it; a serie's own page grows its planches
the same way (`serie/display.html.twig`).

`priority` is the third: `:priority="true"` has the listing's first cover fetched first (`loading="eager"
fetchpriority="high"`) instead of lazily — its first two in `Book:Books`, the row a phone shows. Only the listing opening its page carries it — the three index pages
do; a second listing lower down would compete for the LCP.

`displayMore` is the other prop those three take: it prints a last tile leading to the whole listing. Pass it
as `:displayMore="true"` — a block interpolates its checkbox as `"1"`/`""` where a page writes `true`/`false`
spelled out, so the component brings the value down with `|to_bool` rather than comparing it to a string. The
tile's two words come from the `book` domain, **`label.books_more`/`label.series_more`** for the title and the
short `label.books`/`label.series` for the button: a site whose catalog is not called "books" overrides those
keys in its own `translations/book.*.xlf` instead of overriding the component.

`level` is the rank of the cards' titles, `h3` by default: a block hands `h2` down when it draws no head of its
own, the cards then sitting straight under the page's `<h1>`, and a page with nothing between its `<h1>` and the
grid passes `level="h2"` itself (`strip/index.html.twig`). `Book:Books`, `Serie:Series`, `Category:Categories`
and `Contributor:Contributors` take it and hand it to each card and to the closing tile. The rank never changes
the size: `.book-card__title` reads `--book-card-title-size`.

The count is what is **on screen**, `{{ books|length }}`, because the controller writes it back after each
append from the list it holds — a figure counting the pages behind it would jump backwards.

The page itself comes from `BookService`, `SerieService`, `ContributorService` and `StripService`, which
paginate with UiBundle's `Paginator` (`c975L\UiBundle\Model\Pagination`, 10 books or series per page,
24 planches). The route and
the query the next page's url is rebuilt from ride on it — `pagination.route`, `pagination.query({...})` —
so a filter or a search the visitor came with survives the jump.

## Reading a book's files and its family

```twig
{{ book_cover(book) }}                    {# what stands for the book: its cover field, then its first image #}
{{ book_back_cover(book) }}               {# its fourth cover, another version's for want of one #}
{{ book_media(book, 'flipbook') }}        {# one file of a kind #}
{{ book_medias_of_kind(book, 'page') }}
{{ book_audio_medias(book) }}
{{ book_translations(book) }}             {# read from either end of the family #}
{{ book_language_label(book.language) }}  {# named in the page's language, via symfony/intl #}
{{ book_versions(book) }}                 {# the other versions, both ends of the chain #}
{{ book_alternates(book) }}               {# the hreflang group, absolute urls #}
{{ contributor_path(person) }}            {# a person's page, null where the site serves that family itself #}
```

`book_cover()` falls back on the first **image** and not the first file — a catalog storing a recording
before its cover would otherwise put an `mp3` in an `<img>` — and, failing that, on the cover of another
version of the same text. `book_back_cover()` borrows the fourth cover the same way.

A media row now has to carry a file, a name or a hosted address (`Media::validateFileOrAddress()`), so an
entry added and saved with nothing in it is refused by the form instead of standing in front of the
fallback: `covers|first` never answers an empty row any more.

## What a planche shows

`StripMediaKind` names the four roles a `StripMedia` carries — `case`, `page`, `thumbnail`, `card` — and
`Strip::getMediasByKind()` reads them. `Strip:Medias` places each one: the panels in a slider under *Panel by
panel*, then the whole page they were cut from, then the file to print. A planche whose medias tell no role
apart falls back on the single gallery, which is what a site whose planches are one picture keeps getting.

A page carrying its high resolution opens it over the page through UiBundle's `<twig:c975LUi:Image:Zoom>`
(**CoreBundle 1.26 and up**). `strip_highres(media)` (`BookHighresExtension`) guards it: the derivative's name
can be spelled for every media, but the file only exists for what was uploaded since — a planche predating it
is shown without a zoom rather than with one leading to a 404, and one no wider than the stored file is not offered either.
`BookHighresExtension::highres()` gives the same answer for any media, and is what `book_listen_json` hands the apps.

**Read a media by its role, never by its rank.** `Strip:Card` reads `Strip::getThumbnailMedia()` — thumbnail,
then card, then whatever comes first — and the page's `og:image` reads `Strip::getShareMedia()`, page, then card, then the first: two
different orders, and neither is `medias|first`.

## The editor's pencil

`book_edit_urls(book)`, `serie_edit_urls(serie)`, `contributor_edit_urls(contributor)` and
`strip_edit_urls(strip)` (`BookEditUrlExtension`) map a section's **anchor** to the property it is written
in; `book_edit_url()`, `serie_edit_url()`, `contributor_edit_url()` and `strip_edit_url()` answer one. The
overlay itself is UiBundle's (`data-block-edit-url`, `focusField`) — nothing of it is written here beyond
that map. A section written on another screen than the entity's own —
a serie's list of books, which each book carries itself — is deliberately left **without** a pencil rather
than given one leading nowhere.

**A section added to a page must be added to that map**, or its pencil disappears; a field renamed in the
CRUD must be renamed there too, or the pencil opens the wrong collection. A planche's picture area carries
three of them, one per collection of the edit screen: `medias` for the single gallery, `cases` for the panels
and `page` for the whole page — `Strip:Medias` receives the three and lays each on the section it belongs to.

## Styling

Everything is a CSS custom property, declared in `sass/_variables.scss` and shipped commented-out for a
site in `scaffold/assets/styles/themes/book.css`. No colour is offered: a book page is laid on the site's
own ground and ink, read through UiBundle's `--background` / `--text` / `--primary`. The one exception is
`--book-link-tile-*`, a platform's mark being drawn in its brand's colours and needing a light plate.

The pages are mobile first: one column, then two from `1200px`. Four settings change the look without
touching CSS: `book-strip-card` (the card alone or the drawing), `book-strip-card-icon`, `book-strip-rating-icon`
and `book-confetti`; `book-rating` turns the reader score off for all three families.

`book-strip-card` on `text` decides more than an order: the planche shows its card and **not** its medias,
which are the same reply set in an image — read twice on one page otherwise, the second time in a picture no
screen reader reaches. The medias stay as the page's `og:image` and its structured data's `image`. The card
then takes back the square it wears in a listing (`--book-strip-card-full-size`).

A card's title keeps one size whatever its rank (`--book-card-title-size`): an `<h2>` card would otherwise take
the theme's page-heading size.

`public/css/styles.css` and `styles.min.css` are **generated from `sass/`** — edit the SCSS and rebuild,
never the built files. `sass/block-thumbs.scss` builds the second sheet the same way.

That second one draws the **silhouettes of this bundle's block kinds**, one rule per kind
(`.ui-block-thumb--book_series`, `--book_books`…), on the frame and the five parts UiBundle's
`sass/_block-thumbs.scss` defines. `Service\StylesheetProvider` implements
`BundleStylesheetManagementProviderInterface` beside `BundleStylesheetProviderInterface`, so
`getManagementStylesheets()` contributes it to the back-office alone (`ui.management_stylesheet`) — where
UiBundle's visual picker shows each kind at a glance instead of a bare frame. A site printing those
silhouettes on a public page — a block showcase — contributes the same file through its own stylesheet
provider, rather than every site carrying it on every page for the one that has such a page.

## What each page publishes as JSON-LD

| Page | Function | Graph |
| --- | --- | --- |
| a book, a serie, a planche | `book_json_ld()`, `serie_json_ld()`, `strip_json_ld()` | `Book`, `BookSeries`, `ComicStory` — a book carrying one `VideoObject` per platform video (`buildVideos()`, the same ones its sitemap entry declares, its `uploadDate` read from `Media::$uploadDate` as filled by `c975l:book:video-upload-dates`, the book's publication standing in, never later than today), naming its `translator`, the one part schema.org holds on a `CreativeWork`, and its `aggregateRating` only when `withRating` (the `book-rating` switch) is passed |
| a person the catalog credits | `contributor_json_ld()` | `ProfilePage` whose `mainEntity` is a `Person` — name, portrait, summary, their own site as `sameAs` |
| any page carrying the trail | `book_breadcrumb_json_ld()` | `BreadcrumbList`, emitted by `Breadcrumb.html.twig` itself |
| the four listings | `book_item_list_json_ld()` | `ItemList`, numbered from the offset the page starts at |

All of them are built by `Service\BookSnippetBuilder` and rendered by `Twig\BookJsonLdExtension`, never by a
template of the bundle: a site overriding a page keeps its structured data by calling the same function. The urls
are resolved by the caller, only a template turning a path into an absolute one — and a level whose path is null
(a family this site does not serve, see `book_path()`) is left out rather than published empty.

The breadcrumb sits in the component and not in the three pages for one reason: that is where the hierarchy is
already resolved, the hidden serie and the missing index included. A trail of one level publishes nothing.

## The catalog blocks and their cache

The six kinds putting a selection of the catalog on a page — `book_series`, `book_categories`, `book_books`,
`book_to_be_published`, `book_serie_strips`, `book_contributors` — resolve their content live through
`book_block_*()` (`BookBlockExtension`), on rows UiBundle's `BlockCacheInvalidationListener` does not watch:
it only ever drops the Block that was edited. They are cached all the same, `Service\BookBlockCacheTagProvider`
putting `BookBlockCacheInvalidator::CACHE_TAG_CATALOG` on their entry and `Listener\BookCacheInvalidationListener`
dropping it whenever a book, a serie, a category, a planche, a contributor or one of their medias is saved or removed.
The same tag rides on the menu entries `Management\LinkableRouteProvider` exposes (`LinkableRouteCacheTagsInterface`)
and on each card of the `book.collection.books` sources (`BookCollectionSourceProvider`, no tag while a book is
dated ahead); a `book-route-*` setting saved drops it too, a family of pages turned on or off changing those entries.

`book_books` and `book_to_be_published` read `published <= now`, and `book_serie_strips` the same on a planche.
Their resolver therefore answers `null` — render live, store nothing — for as long as
`BookRepository::hasScheduled()` or `StripRepository::hasScheduled()` says something is dated ahead: an entry
never expires, and nothing is saved the day that date comes round. Both answers are read once per request and
dropped by `reset()`. `book_series` reads no date in any of its three variants and is cached under the tag alone,
as `book_categories` and `book_contributors` are - the tile and the vignette printed how many books a serie had
out until that line was dropped, which is what used to make them follow the scheduled books.
A listing whose `random` is set answers `null` too, whatever the calendar: a cached entry
would freeze one single draw until the catalog itself changed.

`book_books` also takes a category slug, and prints that shelf alone rather than the whole catalog — a slug
and not an entity, block data being stored as JSON, the same way `book_serie_strips` names its series and
`book_series` its own, both reading a comma-separated list and drawing the rows in the order they were typed.

A seventh kind, `book_reader`, holds its own content instead of querying anything: `Form\Block\ReaderBlockType`
stores the album's pages in order then its recording, and a `cues` row for each page the voice turns
(`Form\Block\ReaderCueType` — the second of the recording it is turned at), none where the pages are turned by
hand, so its entry goes stale on a Block event alone and it needs no resolver at all. It drives UiBundle's
`slider` through the slider's own dots, the two staying independent; its pages crossfade rather than slide
(`.book-reader` overriding the slider's `animation-name`), with no animation under `prefers-reduced-motion`.

The six listing kinds each carry their own head: anchor, eyebrow, title, paragraph, colored flat and a link
closing it (`linkLabel` and `linkUrl`, both needed), shared through
`Form\Block\AbstractBookListingBlockType` and drawn by the `Listing:Section` component, which wraps the grid
in `.book-listing` and includes UiBundle's `Section/_head.html.twig`. Do not compose a listing by placing a
`text_section` above it: the two were one section on the page and two rows in the back office. `eyebrow`,
`title`, `content` and `linkLabel` are declared `translatable` on each kind's tag, so a site reading in more
than one language translates what its editor typed — `linkUrl` is an address and is not among them.

## Do not

- **Do not add a second `h1`** to a display page — the hero carries the only one.
- **Do not read a section off its `anchor`** — `key` is the stable name; a site renames the anchor.
- **Do not compute in a template what `book_sections()` already answers** — the summary and the page must agree.
- **Do not pass a section's contents through an `{% embed %}` block** — hand `_section.html.twig` a rendered `body`.
- **Do not give the summary a title or an anchor** — it is not a destination.
- **Do not link to a public route with `path()`** — `book_path()` and its siblings answer null for an emptied prefix and keep the language being read.
- **Do not hand a row to a template without `BookTranslator::apply()`** — its title stays in the writing language.
- **Do not gate a planche on `forEntry()`** — `forStrip()` keeps French dialogue out of "/en".
- **Do not pass `book.language` as a label's locale** — `book_ui_locale(book.language)` follows the language being read.
- **Do not drop a component's props when overriding it** — the language and the pencil ride on them.
- **Do not pass a boolean prop as a string** — `compact="false"` reaches Twig as a true string; write `:compact="false"`.
- **Do not mark two listings of one page with `infinite`** — the controller reads the fetched page through that attribute.
- **Do not edit `public/css/*`** — they are built from `sass/`.
- **Do not ship a block kind without its silhouette** — `sass/block-thumbs.scss` needs a rule per pickable kind, or the picker offers a bare frame.
- **Do not hardcode a colour or a size in `sass/`** — every one goes through a token.
- **Do not add a section to a page without adding it to `BookEditUrlExtension`'s map.**
- **Do not read a planche's representative media off `medias|first`** — ask `getThumbnailMedia()` for a listing, `getShareMedia()` for what a share, a post or a feed shows, or the role the page actually wants.
- **Do not declare a catalog block `cacheable: false`** — carry the catalog tag, and veto the entry only while a date is ahead.

c975L BookBundle — display pages

A page is its hero, then a column of sections. Which sections there are is answered once, by book_sections() / serie_sections(), and read twice — by the hero, to offer the ones a reader comes for, and to render the page — so a section is never offered by a button without being on the page.

Package: c975l/book-bundle · Bundle: c975L\BookBundle\ · Twig namespace: @c975LBook · Translation domain: book

Key source paths (relative to the package root): src/Controller/OnixController.php, src/Controller/ListenJsonController.php, src/Controller/GooglePlayFeedController.php, src/Service/GooglePlayFeed.php, src/Service/BookOnixBuilder.php, src/Service/AudioDurationReader.php, src/Service/BookPlayDurationProvider.php, src/Twig/BookSectionsExtension.php, src/Twig/BookEditUrlExtension.php, src/Twig/BookUrlExtension.php, src/Twig/BookTranslationExtension.php, src/Twig/BookVersionExtension.php, src/Service/BookPublicUrlResolver.php, src/Service/BookTranslator.php, src/Service/BookTranslatedLocales.php, src/Service/BookLinkLocalizer.php, src/Controller/BookController.php, src/Entity/BookSettings.php, src/Controller/Management/BookSettingsCrudController.php, src/Controller/SerieController.php, src/Controller/StripController.php, src/Controller/ContributorController.php, templates/book/display.html.twig, templates/book/_section.html.twig, templates/book/index.html.twig, templates/serie/display.html.twig, templates/serie/index.html.twig, templates/strip/display.html.twig, templates/strip/index.html.twig, templates/contributor/display.html.twig, templates/contributor/index.html.twig, templates/components/, templates/blocks/, src/Enum/StripMediaKind.php, src/Twig/BookHighresExtension.php, src/Form/Block/, src/Service/BookBlockCacheTagProvider.php, sass/_variables.scss, sass/_book.scss, sass/block-thumbs.scss, src/Service/StylesheetProvider.php, scaffold/assets/styles/themes/book.css, src/Command/BookVideoUploadDatesCommand.php

Related skills: c975l-book-customization and c975l-book-lifecycle in this same bundle, and c975l-blocks, c975l-media, c975l-ui-assets in UiBundle beside it.

The pages

Route URL Answers
book_index /livres the catalog, growing on scroll, under the line and the blocks composed on the Books page screen (BookSettings, a single row)
book_display /livre/{slug} one book — 410 for a trashed one, 301 from its number
serie_index /series the series telling books, growing on scroll
serie_display /series/{slug} or /strips/{slug} one serie, under the index listing it
book_category_index unset by default the categories holding a shown book, growing on scroll
book_category_display unset by default one category — the books filed under it
contributor_index /auteurs the people the catalog credits, growing on scroll
contributor_display /auteur/{slug} one person — what they signed, books then series
strip_index /strips the series telling planches, not the planches, growing on scroll
strip_display /strip/{slug} one planche
book_shortcut / strip_shortcut /b3, /s3 301 to the slug
book_listen_index unset by default the books holding a recording, in the language being read
book_listen unset by default one book played — its pages turning along the voice when it carries a cues file
book_listen_json unset by default the listening pages as JSON, at the listening prefix followed by .json (ListenJsonController)
book_onix unset by default the catalog as an ONIX 3.0 feed for the stores — one product per edition holding an ISBN-13 and ticked "ONIX", an edition ticked "Out of print" announced so (OnixController, BookOnixBuilder)
book_google and below unset by default the folders Google Play Books fetches, behind HTTP Basic — the ebooks and audiobooks ticked "Google" — an audiobook only once it has its own square cover —, their ONIX, files and JPEG covers (GooglePlayFeedController, GooglePlayFeed)

Every prefix is a ConfigBundle setting (book-route-books, book-route-book, book-route-series, book-route-categories, book-route-contributors, book-route-contributor, book-route-strips, book-route-strip, book-route-book-shortcut, book-route-strip-shortcut, book-route-listen, book-route-onix) — see c975l-book-customization. A prefix emptied means the site serves that page itself, and the route answers nothing: anything linking to it goes through BookPublicUrlResolver::resolveLocalizedPath(), which returns null rather than throwing. book_path(), serie_path(), serie_url(), contributor_path(), contributor_url() and book_category_path() already do.

Every one of these pages answers in each language the site declares too. Its localised twin — book_display_localized beside book_display, serie_index_localized beside serie_index, and so on — answers /{_locale}/… behind the very same condition, whether or not the row has been translated yet: "/en" is the language the catalog is read in (BookTranslatedLocales). A planche is the exception: its text is the whole of it, so it answers only in the languages it was written in (forStrip()), and its url in any other 301s to the bare one rather than answering 404 — the sitemap and its hreflang group name those languages alone. The bare url stays the writing language's own. A link goes through book_path() and its siblings, which answer in the language being read — path('book_display', …) sends a visitor reading "/en" back into the writing language at the first click. BookPublicUrlResolver::resolvePath() stays the canonical url, the one a sitemap, a hreflang group and a JSON-LD node declare.

book-route-categories and book-route-listen are the two prefixes shipping empty: a site serves no category page until it says under which word it wants them, and the categories a book carries still print on its sheet as plain words — which is a site using them as tags rather than as rayons. The listening pages are a site's choice the same way.

The listening pages (ListenController) play a book by its cues media, a WebVTT file uploaded on the "Page timecodes" field — one cue per page, its identifier the page's number. A book carrying one is handed to <twig:c975LBook:Reader:Reader cuesFile="{{ book.cues }}"> with book.pages (a sound rings at each page the voice reaches when book-page-sound is on, pageSound naming another file); a book without one is shown as its cover and the player, a few pages to leaf through before buying being no book to follow along. A book with no recording the player can read — book_audio_medias(), an audio/* file: mp3, ogg, m4a or wav — answers 404 there and stays off the index, whatever kind its files were filed under; so does a book still to come (isToBePublished()). reader.js follows UiBundle's slider:changed event, so a page turned by arrow, tap or swipe moves the recording too. book_listen also mounts UiBundle's <twig:c975LUi:Pwa:Download> with every file the player needs offline — the recording, the cover, and with cues the pages, the timecodes and the page-turn sound — and book_listen_index links to ui_pwa_downloads when pwa_enabled().

book_listen_json is the same list as data, for a reader outside the site (ListenJsonController): one answer holding pageSound and books, each with its id, slug, title, summary, language, modification, cover, coverBack (the fourth cover, another version's for want of one as book_back_cover() reads it, null without), coverHighres/coverBackHighres/pagesHighres (the -highres.webp beside each, null where none is on disk), audio (url, type — the mp3 first), pages, cues, published, age, serie (its title translated, number), credits (role, name, the roles named in book_ui_locale()), videos (title, youtube, url), plays (null below book-plays-minimum, as on the pages: BookPlaysExtension::shown()), playStart and play (the absolute ui_play_start and ui_play urls an app takes its ticket from and hands it back to, as the listening page does), data (the fields of Book::$data the site lists in book-listen-data, none by default) and page (the book's own page in the asked language, null where the site serves none). Every url is absolute, asked to VichUploader then to UrlHelper. pages and cues come together or not at all: a book with no timecodes, or with a page or cues row holding no file, answers pages: [] and cues: null. The answer carries a weak ETag and stays private. Its routes sit behind BookRoutePrefix::LISTEN_CONDITION, the one condition of every listening route, and list the books BookSectionsExtension::withAudio() keeps. Credits and videos are loaded for the whole list by BookRepository::withCreditsAndVideos().

A story heard far enough counts one listen: book_listen wraps its player in UiBundle's ui-play-count controller, taking a ticket from ui_play_start and posting it to ui_play under BookFavoriteItemProvider::OWNER_TYPE once ui-play-ratio of it is reached; BookPlayDurationProvider tells UiBundle how long the recording lasts (the mp3, read by AudioDurationReader::ofMedia()), so a story jumped to its end counts nothing; a book with no readable mp3 counts on the ticket alone. book_plays(book) (BookPlaysExtension) reads the total, site and apps added up, and answers null below book-plays-minimum; a book's page prints it above its sections, out of their cache, and book_listen under its player.

serie_display is one route for both families: a serie's own SerieKind decides which index a serie sits under, and one asked under the other segment answers a 301 to its own.

The skeleton of a page

{# what the page holds, answered once #}
{% set sections = book_sections(book) %}

<twig:c975LBook:Breadcrumb .../>     {# where the page sits, and the way back out #}
<twig:c975LBook:Book:Hero book="{{ book }}" .../>   {# the page's only h1 #}
<twig:c975LBook:Book:Resume book="{{ book }}"/>     {# no title, no anchor: not a destination #}
{% for section in sections %}…{% endfor %}

The hero carries the only h1 — the layout's own title block is left empty by the three display templates, which render Book:Hero, Serie:Hero or Strip:Hero inside the container instead. A second h1 anywhere on these pages is a bug.

The summary is no section. It is the sentence the page opens on, printed by Book:Resume / Serie:Resume under the hero and outside the sections grid, with no title and no anchor. Neither a book's page nor a serie's carries a summary of anchors any more: the hero's row of buttons opens what a reader looks for — pages, podcasts, apercu, shops, presse, marketing, in the order the page lays them out, and only those it actually holds — and the page keeps its whole width. sass/_book.scss opens the two-column layout on .book-page:has(> .book-page__toc) alone — a page with no toc that grows one gets two columns back.

The sections

Owner Keys, in the bundle's order
a book pages, podcasts, apercu, crowdfunding, shops, presse, marketing, informations
a serie characters, books, strips

A section is present only when the book actually holds something for it, informations excepted — a book with no field filled in still says when it comes out and who wrote it. A serie's characters asks more than that: it is written for the serie whose people carry a presentation or a portrait, a bare list of names being the row of chips a planche already prints. Each entry carries key, anchor, label and, when the catalog holds one, intro. key is the name everything else uses — the site's declaration, the pencil's map of fields; anchor is only what the page writes as an id, and a site may rename it. Reading a section off its anchor is what silently loses a button when a site renames one.

Every section is rendered through templates/book/_section.html.twig, which owns the anchor, the name, the icon, the entrance effect, the site's own fragment and the pencil — a section component writes nothing but what goes inside:

{% set body %}…{% endset %}
{{ include('@c975LBook/book/_section.html.twig', {book: book, section: section, editUrl: editUrl, body: body}) }}

The contents arrive as an already-rendered body string and never as a block an {% embed %} overrides: a block declared by an embed never reaches the inside of a component's slot, and the card came out titled and empty, which neither the Twig lint nor a template test can see.

form decides the shape, not the contents: text and framed are written as a plain <section> (prose carries no frame, so those two do the anchor, the title and the pencil themselves), anything else is a c975LUi:Card:Card. Every section wears UiBundle's toc-target.

A book's sections are cached (book/_sections.html.twig, {% cache %} tagged book_catalog and blocks_all): one entry per book and language, living until midnight (book_sections_cache_ttl()), dropped by BookCacheInvalidationListener on anything a section prints, its book_* translations included, and on any book-* setting — once per flush. Rendered live for an editor and when book_sections_cacheable(sections) is false — a section declaring cache: false, or carrying a template without cache: true. Nothing per visitor goes inside it.

The components a site overrides

They all live in templates/components/ and are overridden in templates/bundles/c975LBookBundle/components/. The ones a site touches most:

Component Prints
<twig:c975LBook:Book:Hero> the opening — cover (flipped over on a click given a fourth), backdrop, eyebrow, title, a button per section reached from there
<twig:c975LBook:Book:Resume> the sentence under the hero
<twig:c975LBook:Book:Pages> the pages, in UiBundle's slider
<twig:c975LBook:Book:Flipbook> the video turning them, under the hero and outside any card
<twig:c975LBook:Book:Podcasts> one card for everything the book is listened to by, plus a link to book_listen when the book carries timecodes and pages. While isToBePublished(): the podcast apps alone, no player and no download, and the section goes when no link is left
<twig:c975LBook:Book:Videos> one card for everything it is watched by
<twig:c975LBook:Book:Shops> the bookshops, the site's own shop first and in the site colour — titled "Get it free" rather than "Buy" when Book::isFree() (a digital edition whose public price is 0)
<twig:c975LBook:Book:Informations> the framed civil status — author, illustrator, everyone else the book credits, serie, editions, languages, versions
<twig:c975LBook:Book:Card> a book in a listing: cover in portrait, summary, language — :compact="true" prints it at a thumbnail's width, without its summary
<twig:c975LBook:Contributor:Hero> a person's opening — portrait, backdrop, name, what they are credited under (Contributor::getRoles(), which counts only what the catalog still shows), the sentences they are introduced with
<twig:c975LBook:Contributor:Shops> where their books are bought: the same card and the same tiles as a book's, under #shops
<twig:c975LBook:Contributor:Contributor> a person in a listing: portrait squared, name, what they are credited under, the whole card leading to their page
<twig:c975LBook:Strip:Card> a planche, the whole card being the link
<twig:c975LBook:Strip:Characters> who speaks, as chips narrowing the serie's listing — the face each wears is the one uploaded on their own screen
<twig:c975LBook:Serie:Characters> who peoples the serie, presented above what it holds, parted into rows by Character::$groupName — drawn only where a character carries a presentation or a portrait
<twig:c975LBook:Strip:Medias> what a planche shows, each media placed by its role — takes editUrl, casesEditUrl and pageEditUrl, one per collection
<twig:c975LBook:Strip:Previous> / <twig:c975LBook:Strip:Next> the arrows laid over the planche
<twig:c975LBook:Breadcrumb> the trail, for all three pages

A component overridden must pass its props through. Book:Videos, Book:Presses and Book:Marketings take the book itself rather than the collection, so they can print their title in the book's language; Book:Book and Book:Books take editable and hand editUrl down to the card, which is what keeps the pencil. Dropping either leaves a right page with a wrong language or no pencil at all.

The language a page is read in

A row's own texts — a title, a summary, a character's name, a picture's title — are laid over it by BookTranslator::apply() just before the render, in the language being read, and never persisted. The controllers call it on everything their page prints, the serie a book belongs to and the neighbours of a planche included. Anything else handing a row to a template calls it first — a site's own controller, a block resolver, a provider copying a title into an item — or prints the writing language's text among labels already turned.

The labels this bundle prints read book_ui_locale(book.language) rather than book.language: the language the url reads where there is one, the row's own where there is none, which is every url of a single-language site. The book's own language stays a fact about the book, printed by book_language_label() in the request's locale (BookTranslationExtension::languageName() for another one). Strip:Characters receives Character entities (StripServiceInterface::findCharactersBySerie()), so their names follow too.

A catalog link an editor stored in a block — a card, a call to action, a word linked in a rich text — is rewritten the same way by BookLinkLocalizer, its patterns built from the configured prefixes at each call.

A listing that grows

The three index pages grow as the visitor scrolls instead of turning pages: UiBundle's infiniteScroll controller fetches the page the listing's own "next" link points at and appends the cards found there. Nothing is written for it alone — that link is an ordinary rel="next" link to the next page, which is what a crawler follows and what happens without javascript or after a failed fetch.

<div id="books" data-controller="infiniteScroll">
    <twig:c975LBook:Book:Books books="{{ books }}" :infinite="true"/>
    <p><span data-infiniteScroll-target="count">{{ books|length }}</span> / {{ books.getTotalItemCount }}</p>
    <a href="{{ path(books.route, books.query({'p': books.getCurrentPageNumber + 1})) }}#books"
       rel="next" data-infiniteScroll-target="next" data-action="click->infiniteScroll#load">…</a>
</div>

infinite is what marks the grid the cards land in (data-infiniteScroll-target="list"), and it is the attribute the controller reads the fetched page through: only the listing that grows may carry it — a search's results sitting on the same page would otherwise answer in its place. Book:Books, Serie:Series, Contributor:Contributors and Strip:Cards take it; a serie's own page grows its planches the same way (serie/display.html.twig).

priority is the third: :priority="true" has the listing's first cover fetched first (loading="eager" fetchpriority="high") instead of lazily — its first two in Book:Books, the row a phone shows. Only the listing opening its page carries it — the three index pages do; a second listing lower down would compete for the LCP.

displayMore is the other prop those three take: it prints a last tile leading to the whole listing. Pass it as :displayMore="true" — a block interpolates its checkbox as "1"/"" where a page writes true/false spelled out, so the component brings the value down with |to_bool rather than comparing it to a string. The tile's two words come from the book domain, label.books_more/label.series_more for the title and the short label.books/label.series for the button: a site whose catalog is not called "books" overrides those keys in its own translations/book.*.xlf instead of overriding the component.

level is the rank of the cards' titles, h3 by default: a block hands h2 down when it draws no head of its own, the cards then sitting straight under the page's <h1>, and a page with nothing between its <h1> and the grid passes level="h2" itself (strip/index.html.twig). Book:Books, Serie:Series, Category:Categories and Contributor:Contributors take it and hand it to each card and to the closing tile. The rank never changes the size: .book-card__title reads --book-card-title-size.

The count is what is on screen, {{ books|length }}, because the controller writes it back after each append from the list it holds — a figure counting the pages behind it would jump backwards.

The page itself comes from BookService, SerieService, ContributorService and StripService, which paginate with UiBundle's Paginator (c975L\UiBundle\Model\Pagination, 10 books or series per page, 24 planches). The route and the query the next page's url is rebuilt from ride on it — pagination.route, pagination.query({...}) — so a filter or a search the visitor came with survives the jump.

Reading a book's files and its family

{{ book_cover(book) }}                    {# what stands for the book: its cover field, then its first image #}
{{ book_back_cover(book) }}               {# its fourth cover, another version's for want of one #}
{{ book_media(book, 'flipbook') }}        {# one file of a kind #}
{{ book_medias_of_kind(book, 'page') }}
{{ book_audio_medias(book) }}
{{ book_translations(book) }}             {# read from either end of the family #}
{{ book_language_label(book.language) }}  {# named in the page's language, via symfony/intl #}
{{ book_versions(book) }}                 {# the other versions, both ends of the chain #}
{{ book_alternates(book) }}               {# the hreflang group, absolute urls #}
{{ contributor_path(person) }}            {# a person's page, null where the site serves that family itself #}

book_cover() falls back on the first image and not the first file — a catalog storing a recording before its cover would otherwise put an mp3 in an <img> — and, failing that, on the cover of another version of the same text. book_back_cover() borrows the fourth cover the same way.

A media row now has to carry a file, a name or a hosted address (Media::validateFileOrAddress()), so an entry added and saved with nothing in it is refused by the form instead of standing in front of the fallback: covers|first never answers an empty row any more.

What a planche shows

StripMediaKind names the four roles a StripMedia carries — case, page, thumbnail, card — and Strip::getMediasByKind() reads them. Strip:Medias places each one: the panels in a slider under Panel by panel, then the whole page they were cut from, then the file to print. A planche whose medias tell no role apart falls back on the single gallery, which is what a site whose planches are one picture keeps getting.

A page carrying its high resolution opens it over the page through UiBundle's <twig:c975LUi:Image:Zoom> (CoreBundle 1.26 and up). strip_highres(media) (BookHighresExtension) guards it: the derivative's name can be spelled for every media, but the file only exists for what was uploaded since — a planche predating it is shown without a zoom rather than with one leading to a 404, and one no wider than the stored file is not offered either. BookHighresExtension::highres() gives the same answer for any media, and is what book_listen_json hands the apps.

Read a media by its role, never by its rank. Strip:Card reads Strip::getThumbnailMedia() — thumbnail, then card, then whatever comes first — and the page's og:image reads Strip::getShareMedia(), page, then card, then the first: two different orders, and neither is medias|first.

The editor's pencil

book_edit_urls(book), serie_edit_urls(serie), contributor_edit_urls(contributor) and strip_edit_urls(strip) (BookEditUrlExtension) map a section's anchor to the property it is written in; book_edit_url(), serie_edit_url(), contributor_edit_url() and strip_edit_url() answer one. The overlay itself is UiBundle's (data-block-edit-url, focusField) — nothing of it is written here beyond that map. A section written on another screen than the entity's own — a serie's list of books, which each book carries itself — is deliberately left without a pencil rather than given one leading nowhere.

A section added to a page must be added to that map, or its pencil disappears; a field renamed in the CRUD must be renamed there too, or the pencil opens the wrong collection. A planche's picture area carries three of them, one per collection of the edit screen: medias for the single gallery, cases for the panels and page for the whole page — Strip:Medias receives the three and lays each on the section it belongs to.

Styling

Everything is a CSS custom property, declared in sass/_variables.scss and shipped commented-out for a site in scaffold/assets/styles/themes/book.css. No colour is offered: a book page is laid on the site's own ground and ink, read through UiBundle's --background / --text / --primary. The one exception is --book-link-tile-*, a platform's mark being drawn in its brand's colours and needing a light plate.

The pages are mobile first: one column, then two from 1200px. Four settings change the look without touching CSS: book-strip-card (the card alone or the drawing), book-strip-card-icon, book-strip-rating-icon and book-confetti; book-rating turns the reader score off for all three families.

book-strip-card on text decides more than an order: the planche shows its card and not its medias, which are the same reply set in an image — read twice on one page otherwise, the second time in a picture no screen reader reaches. The medias stay as the page's og:image and its structured data's image. The card then takes back the square it wears in a listing (--book-strip-card-full-size).

A card's title keeps one size whatever its rank (--book-card-title-size): an <h2> card would otherwise take the theme's page-heading size.

public/css/styles.css and styles.min.css are generated from sass/ — edit the SCSS and rebuild, never the built files. sass/block-thumbs.scss builds the second sheet the same way.

That second one draws the silhouettes of this bundle's block kinds, one rule per kind (.ui-block-thumb--book_series, --book_books…), on the frame and the five parts UiBundle's sass/_block-thumbs.scss defines. Service\StylesheetProvider implements BundleStylesheetManagementProviderInterface beside BundleStylesheetProviderInterface, so getManagementStylesheets() contributes it to the back-office alone (ui.management_stylesheet) — where UiBundle's visual picker shows each kind at a glance instead of a bare frame. A site printing those silhouettes on a public page — a block showcase — contributes the same file through its own stylesheet provider, rather than every site carrying it on every page for the one that has such a page.

What each page publishes as JSON-LD

Page Function Graph
a book, a serie, a planche book_json_ld(), serie_json_ld(), strip_json_ld() Book, BookSeries, ComicStory — a book carrying one VideoObject per platform video (buildVideos(), the same ones its sitemap entry declares, its uploadDate read from Media::$uploadDate as filled by c975l:book:video-upload-dates, the book's publication standing in, never later than today), naming its translator, the one part schema.org holds on a CreativeWork, and its aggregateRating only when withRating (the book-rating switch) is passed
a person the catalog credits contributor_json_ld() ProfilePage whose mainEntity is a Person — name, portrait, summary, their own site as sameAs
any page carrying the trail book_breadcrumb_json_ld() BreadcrumbList, emitted by Breadcrumb.html.twig itself
the four listings book_item_list_json_ld() ItemList, numbered from the offset the page starts at

All of them are built by Service\BookSnippetBuilder and rendered by Twig\BookJsonLdExtension, never by a template of the bundle: a site overriding a page keeps its structured data by calling the same function. The urls are resolved by the caller, only a template turning a path into an absolute one — and a level whose path is null (a family this site does not serve, see book_path()) is left out rather than published empty.

The breadcrumb sits in the component and not in the three pages for one reason: that is where the hierarchy is already resolved, the hidden serie and the missing index included. A trail of one level publishes nothing.

The catalog blocks and their cache

The six kinds putting a selection of the catalog on a page — book_series, book_categories, book_books, book_to_be_published, book_serie_strips, book_contributors — resolve their content live through book_block_*() (BookBlockExtension), on rows UiBundle's BlockCacheInvalidationListener does not watch: it only ever drops the Block that was edited. They are cached all the same, Service\BookBlockCacheTagProvider putting BookBlockCacheInvalidator::CACHE_TAG_CATALOG on their entry and Listener\BookCacheInvalidationListener dropping it whenever a book, a serie, a category, a planche, a contributor or one of their medias is saved or removed. The same tag rides on the menu entries Management\LinkableRouteProvider exposes (LinkableRouteCacheTagsInterface) and on each card of the book.collection.books sources (BookCollectionSourceProvider, no tag while a book is dated ahead); a book-route-* setting saved drops it too, a family of pages turned on or off changing those entries.

book_books and book_to_be_published read published <= now, and book_serie_strips the same on a planche. Their resolver therefore answers null — render live, store nothing — for as long as BookRepository::hasScheduled() or StripRepository::hasScheduled() says something is dated ahead: an entry never expires, and nothing is saved the day that date comes round. Both answers are read once per request and dropped by reset(). book_series reads no date in any of its three variants and is cached under the tag alone, as book_categories and book_contributors are - the tile and the vignette printed how many books a serie had out until that line was dropped, which is what used to make them follow the scheduled books. A listing whose random is set answers null too, whatever the calendar: a cached entry would freeze one single draw until the catalog itself changed.

book_books also takes a category slug, and prints that shelf alone rather than the whole catalog — a slug and not an entity, block data being stored as JSON, the same way book_serie_strips names its series and book_series its own, both reading a comma-separated list and drawing the rows in the order they were typed.

A seventh kind, book_reader, holds its own content instead of querying anything: Form\Block\ReaderBlockType stores the album's pages in order then its recording, and a cues row for each page the voice turns (Form\Block\ReaderCueType — the second of the recording it is turned at), none where the pages are turned by hand, so its entry goes stale on a Block event alone and it needs no resolver at all. It drives UiBundle's slider through the slider's own dots, the two staying independent; its pages crossfade rather than slide (.book-reader overriding the slider's animation-name), with no animation under prefers-reduced-motion.

The six listing kinds each carry their own head: anchor, eyebrow, title, paragraph, colored flat and a link closing it (linkLabel and linkUrl, both needed), shared through Form\Block\AbstractBookListingBlockType and drawn by the Listing:Section component, which wraps the grid in .book-listing and includes UiBundle's Section/_head.html.twig. Do not compose a listing by placing a text_section above it: the two were one section on the page and two rows in the back office. eyebrow, title, content and linkLabel are declared translatable on each kind's tag, so a site reading in more than one language translates what its editor typed — linkUrl is an address and is not among them.

Do not

  • Do not add a second h1 to a display page — the hero carries the only one.
  • Do not read a section off its anchor — key is the stable name; a site renames the anchor.
  • Do not compute in a template what book_sections() already answers — the summary and the page must agree.
  • Do not pass a section's contents through an {% embed %} block — hand _section.html.twig a rendered body.
  • Do not give the summary a title or an anchor — it is not a destination.
  • Do not link to a public route with path() — book_path() and its siblings answer null for an emptied prefix and keep the language being read.
  • Do not hand a row to a template without BookTranslator::apply() — its title stays in the writing language.
  • Do not gate a planche on forEntry() — forStrip() keeps French dialogue out of "/en".
  • Do not pass book.language as a label's locale — book_ui_locale(book.language) follows the language being read.
  • Do not drop a component's props when overriding it — the language and the pencil ride on them.
  • Do not pass a boolean prop as a string — compact="false" reaches Twig as a true string; write :compact="false".
  • Do not mark two listings of one page with infinite — the controller reads the fetched page through that attribute.
  • Do not edit public/css/* — they are built from sass/.
  • Do not ship a block kind without its silhouette — sass/block-thumbs.scss needs a rule per pickable kind, or the picker offers a bare frame.
  • Do not hardcode a colour or a size in sass/ — every one goes through a token.
  • Do not add a section to a page without adding it to BookEditUrlExtension's map.
  • Do not read a planche's representative media off medias|first — ask getThumbnailMedia() for a listing, getShareMedia() for what a share, a post or a feed shows, or the role the page actually wants.
  • Do not declare a catalog block cacheable: false — carry the catalog tag, and veto the entry only while a date is ahead.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect