v2.1.0
What's Changed
Added
-
ViteManifestresolves a content-hashed JS entry through.vite/manifest.json. A library opts in withvite_entry(the manifest key, ortruefor the defaultsrc/js/script.js) and keeps declaring its real dist path;hook_library_info_alter()swaps in the hashed filename when a usable manifest sits beside it. Ported fromStarterBase::themeScriptFile()in parisek/timber-kit.Why the entry needs a hash: lazy chunks always carried one, the entry did not, because
*.libraries.ymlnames it by a fixed path and cache-busting came from Drupal's?v=instead. That covers the reference in the HTML but not the one the bundler emits inside a chunk — a module reachable from the entry graph and from a lazy chunk is hoisted into the entry, and the chunk imports it back as./script.js, unhashed and unqueried. Measured on the WordPress sibling (sloneek, 2026-08-17): 5 of 52 chunks imported the entry,max-agewas 31536000, and a form silently stopped rendering withdoes not provide an export named 'n'— minified export names are positions in a table, so a stale entry can also answer with the wrong binding and no error.This closes the correctness defect, not the double fetch.
JsCollectionRendererappends a query to every unaggregated asset unconditionally, so the tag's URL and a chunk's own import remain two module identities — now with identical content. See ADR 0002, which also records whydrupal/vitewas not taken.vite_entryalso accepts a map of asset path to manifest key, which a library declaring more than one JS asset needs; a bare key covering several assets is refused with a logged warning instead of rewriting one of them.Rewrites keep their declared position (Drupal emits a library's JS in array order, and an unset-and-append moved the rewritten asset last), and a map whose entries resolve to one built filename is refused whole rather than dropping an asset.
Two constraints worth stating: only the asset whose filename matches the key's is rewritten (the property names one entry, and applying the key to every JS file made the rewrites overwrite each other), and the resolved name is cached with Drupal's library info, so a deploy shipping new assets must run
drush cr.Backwards compatible by construction: no
vite_entry, or no manifest, and the declared path is served unchanged. Four guards on the manifest value — resolvable inside the built directory, free of URL-significant characters,.jssuffix, present on disk — each answering a reproduction rather than a hypothesis and each pinned by a mutation-verified test. Every rejection also logs: an opt-in that cannot do its job says so, instead of silently serving a declared path that may 404.
||||||| 682a6f1
Changed
-
|typographynow typesets per language —TypographyExtensionhands the upstreamParisek\Twig\TypographyExtensiona locale resolver, so thelanguages:tables shipped byparisek/twig-typography^1.3 (quote style, dash convention, single-character word spacing, …) actually apply. Without a resolver the upstreamlocaleCandidates()returns[]and that whole layer is inert: only the language-neutral house defaults ever ran, so Czech content was typeset with English curled quotes (“ahoj”, not„ahoj“) and lost the non-breaking space after single-letter prepositions that Czech typography requires. Ported fromStarterBase::typography_locale_resolver()in parisek/timber-kit, the WordPress-side sibling.The resolver reports the content language (
LanguageInterface::TYPE_CONTENT), not the interface language. The two diverge exactly where it matters — an editor whose account language is Czech previewing an English node would otherwise get Czech typography applied to English prose. Drupal falls back to the interface language when content language negotiation is not configured, so monolingual sites are unaffected. Same distinction timber-kit documents forget_locale()vsdetermine_locale().FilterTypographynow forwards its own$langcode. Drupal hands a text filter the language of the exact text being processed, andprocess()was discarding it — harmless while no language layer existed, wrong the moment one did: the filter would have typeset with the negotiated content language instead, which differs on mixed-language views, an explicitly rendered translation, mail and cron.applyTypography()takes an optional fourth argument for it; an empty langcode falls back to negotiation. A pinned language gets its own cache entry, so the negotiated path is unaffected.Note for direct instantiators: the constructor takes a fourth required argument. Container consumers are unaffected; the release doctrine excludes container-wired constructor signatures from the public API.
Exposed as the overridable
protected TypographyExtension::localeResolver()for sites whose language detection does not go throughlanguage_manager. The closure is evaluated perapplyTypography()call rather than once at construction, so one cached upstream instance still serves every language in a request and the per-theme cache needs no language component.This changes rendered output on multilingual sites and on any monolingual site whose language has a
languages:entry — review pages before deploying. Two regression tests pin the contract (Czech low-9 quotes reach the output; one instance typesets Czech and English differently across consecutive calls); both fail against the pre-fix constructor call. -
Bumped the
parisek/twig-typographyfloor from^1.2to^1.3— thelanguages:layer this change depends on does not exist before 1.3, where the resolver argument would be accepted and silently ignored. -
Docs: Packagist is the distribution channel — the package is now published as
parisek/drupal-kiton Packagist with the GitHub auto-sync webhook. README gains Packagist version + downloads badges and an Installation section (composer require parisek/drupal-kit); RELEASING.md drops thevcsrepository entry instructions in favour of a Packagist sync-verification step and documents that the auto-created GitHub release must not be duplicated manually. Packagist serves every tag (including 1.x) under the canonical package name, so thevcsroute is obsolete for all versions.
Fixed
-
The sitemap no longer lists the front page twice —
system.sitepointspage.frontat a node, and simple_sitemap listed that page as both/and the node's own URL. Where the redirect module's route normalizer is enabled the second answers 301, so the file handed crawlers a redirect to a page it already contained.drupal_kit_simple_sitemap_links_alter()drops the duplicate and keeps/, which is what Drupal itself declares canonical on the front page.page.frontis translatable, so the filter is per language. A site can point each language at its own node; askingsystem.siteonce and applying that answer to every link deleted the node matching the generation-time language while leaving the other language's duplicate in place. The hook now builds a langcode → front-page map, judges each link by its ownlangcode, and prunesalternate_urlsper language — a surviving link must not re-advertise the withheld URL through itshreflangblock.Which entry survives is decided by what the page declares canonical, not by preference. Metatag ships
canonical_url: '[site:url]'for the front page as its own default, and a site that disables that group falls back to theglobalgroup's[current-page:url-with-query:…], which on/resolves to/as well — so on any consumer running Metatag, keeping/agrees with the page. On a site without Metatag core declares the node's alias instead, and the two disagree; that limitation is recorded in ADR 0003 rather than argued away.The hook is unconditional, against
AGENTS.md's opt-in default. It only runs where simple_sitemap is installed and only fires on the configured front page, so "always on" means "on exactly where the defect is"; and a flag defaulting to off would not reach the nineteen sites that have the defect and have not noticed. Reasoning, and the narrow precedent it sets, in ADR 0003. -
merge_resizer()supports optional per-viewport images — ported from timber-kit'sStarterBase::twig_merge_resizer()after the OPOP page-header case (optional mobile mascot variant) exposed two defects in the original implementation. (1) Empty groups are now dropped before the last-group detection: an unfilled optional image field makesResizerreturn[], and keeping it as the "last" group filtered the remaining (desktop) group down to media-qualified variants — producing a<picture>with no unconditional<img>fallback at all. (2) The non-last-group filter switchesisset($image['media'])→!empty(...):Resizersetsmediato''for tuples without a breakpoint (and omits the key on the appended original image), soisset()leaked fallback-shaped desktop entries into the merged set ahead of the mobile entries, shadowing the mobile image on every viewport. Net effect:merge_resizer(desktop, mobile)keeps one call shape whether or not the optional image is filled — no{% if %}branching in templates. Two regression unit tests pin the contract (empty-group drop preserves the fallback; empty-media desktop entries are filtered when a mobile group follows). Consumers with a hand-mirrored copy in their theme'sstatic/index.php(styleguide runs without Drupal) must apply the same change — done inopopczanddrupal-base. -
PHPStan drift:
DependencySerializationTraitvs promotedprivate readonlyplugin properties —FilterLinksandFilterTypographyinjected their services as constructor-promotedprivate readonlyproperties;FilterBasecarriesDependencySerializationTrait, which supports neither private nor readonly properties (#3110266), and a newerphpstan-drupalrule now fails the analysis (4 errors) on every fresh install — exactly the drift the no-composer.lockpolicy (ADR 0001) is meant to surface. Both properties are now plainprotected, with an inline comment explaining the constraint. Full suite (347 tests), PHPStan level 8 and phpcs green.
Pull Requests
- #107 — docs: switch distribution docs to Packagist
- #108 — docs: correct Packagist 1.x tag availability note
- #109 — fix: drop empty groups and use !empty(media) in mergeResizer for optional per-viewport images
- #110 — fix: use protected instead of private readonly for DI properties in FilterLinks/FilterTypography
- #111 — feat(typography): apply per-language tables via a content-language resolver
- #112 — feat(assets): resolve a content-hashed JS entry from the Vite manifest
- #116 — fix(sitemap): drop the front page's duplicate node entry from the sitemap
Full Changelog: v2.0.0...v2.1.0