Releases: parisek/drupal-kit
Release list
v2.2.0
What's Changed
Added
-
The module ships its own interface translations (#123) — every string is wrapped in
t()orTranslatableMarkup, so it was translatable in principle. In practice nobody translated it and every site showed English, including the message above an unpublished page on a site whose default language is Czech, and the abbreviated weekday names inEntityHelper::getOfficeHours(), which are user-facing content on a contact page rather than an admin screen.drupal_kit.info.ymlnow declares the module as its own translation project, andtranslations/carriescs.po,sk.po,de.poandpl.pocovering the 28 strings the module owns. Drupal's locale module picks them up ondrush locale:update.The server pattern ends
%language.po, with no closing percent.%languageis the whole placeholder —%language%.poresolves tocs%.po, a file that does not exist, and the import then reports the project as checked while silently importing nothing.Seven strings carry
['context' => 'Abbreviated weekday']and their entries carry the matchingmsgctxt, so they do not collide with the unqualifiedMonthat core already translates.Three generic words the module emits —
Advanced,Available,Not available— are deliberately not translated here. Locale stores a string globally by source and context rather than per project, and core emits the same three untagged, so shipping a translation for them would overwrite core's on every import and flip back on the next core update. Core already translates them.The install path is not assumed.
drupal_kit_locale_translation_projects_alter()rebuilds the server pattern from the extension list, so a consumer whoseinstaller-pathsput the module outsidemodules/contribstill gets its translations instead of a project reported as checked with nothing imported.The scanner that guards the catalogue is held to account by its own test. It unescapes per quote style — a single-quoted PHP literal knows only
\'and\\, so running the double-quoted rules over one turns a literal backslash-n into a newline and silently renames the string — and it reads acontextkey written either way. A shape it cannot read is a false green: the parity check reports a complete catalogue while the string ships untranslated.A consumer still overrides any string in Admin → Translate interface. A shipped translation is a default, not a lock.
-
A Scheduler publish or unpublish date is announced on the entity page (#121) —
drupal_kit_page_attachments_alter()already says This page has not been published yet, only privileged users can see it. When Scheduler holds that page for a date, the message stopped short: it said the content was invisible, not that a date was set and cron would act on it. An editor could not tell a planned article from a forgotten draft without opening the edit form. Scheduler names the date once, in the message after the entity form is saved, so an editor who opens the page a week later saw nothing.The hook now adds Scheduler publishes this content on @Date. and Scheduler unpublishes this content on @Date., after the existing message so the two read as one thought.
Nothing changes on a site without Scheduler, and nothing changes on content that carries no date —
drupal/scheduleris asuggest, never a dependency. The newdrupal_kit.schedule_announcerservice holds the logic and returns text; the caller decides where it goes and the theme decides how it looks, which is the split the existing message already had.The viewer must pass
access('update')on the entity. Anunpublish_ondate leaves the entity published, so an anonymous visitor reaches the page, and the schedule is editorial information. The check is edit access rather than a permission name because Scheduler names its permission per entity type, and this hook serves nodes, taxonomy terms and commerce products alike.A date left on a bundle whose scheduling was switched off is not announced. Scheduler's base fields belong to a whole entity type rather than to the bundles that opt in, so such a value sits in the field forever and cron never acts on it.
Scheduler's own
view scheduled <type>permission also opens the message, alongside edit access. That permission exists for a read-only reviewer role, which by definition has no edit rights.Found on htdvere, where a future-dated article had been publishing itself immediately.
Pull Requests
- #122 — feat(messages): announce a Scheduler publish or unpublish date
- #124 — feat(i18n): ship interface translations for cs, sk, de and pl
Full Changelog: v2.1.2...v2.2.0
v2.1.2
What's Changed
Fixed
-
ViteManifestno longer asks for a theme namedcore(#118) — Drupal runshook_library_info_alter()for thecorepseudo-extension, which is neither a module nor a theme.extensionRoot()decided the type as module-else-theme, so every library-discovery cache rebuild asked the resolver for a theme calledcore. The resolver reports the miss withtrigger_error(), a warning rather than an exception, so thecatch (\Throwable)beside it never ran and the NULL it returned reacheddirname(). Two log entries per rebuild, and a red error box on a site with error display on.extensionRoot()now returns NULL forcorebefore deciding a type.LibraryDiscoveryParser::buildByExtension()carries the same module-else-theme rule and the same single exception, and every other extension name reaches core's owngetPath()call before this hook runs, so mirroring core covers the whole class of names that can arrive here.Found on htdvere. The entries follow the cache rebuild rather than any route, so warming the cache with one page moves the warning to the next one and it reads as route-specific until you look twice.
Pull Requests
- #119 — fix(vite): stop asking the path resolver for a theme named core
Full Changelog: v2.1.1...v2.1.2
v2.1.1
What's Changed
Fixed
-
|typographyno longer fatals on a number or a boolean — the upstream filter's signature isStringable|string|null, and only arrays were guarded, so an int reached it and raised aTypeError: a 500 on the whole page rather than a filter that declined. Ints, floats and booleans now pass through untouched, alongside the render arrays that already did. Passing through rather than casting is deliberate — typography is for prose, a number gains nothing from a non-breaking space, and the value keeps its type for arithmetic or a chained filter further down the template.The list stops there on purpose, and an object still reaches upstream. This filter is registered
is_safe => ['html'], so what it returns is printed unescaped; passing an arbitrary object through would extend that promise to a value the extension never inspected, and Drupal prints an object carrying atoString()method raw. An object reaching a typography filter is a template defect and stays loud.Found while migrating a site off its local
custom_componentscopy, whose filter cast silently: a branch count piped through|typographyrendered there and took two pages down here. Every consumer moving to this package is one such call away from the same page, so the tolerance belongs in the filter rather than in a rule each site has to remember.
Pull Requests
- #117 — fix(typography): pass non-string values through instead of fataling
Full Changelog: v2.1.0...v2.1.1
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 neit...
v2.0.0
What's Changed
Changed
- BREAKING: module machine name renamed
custom_components→drupal_kit— completes the package rename inside Drupal: module files (drupal_kit.info.yml/.module/.install/.services.yml), hook implementations, the PHP namespace (Drupal\custom_components→Drupal\drupal_kitincl. tests), every service ID (custom_components.entity_helper→drupal_kit.entity_helper, …),extra.installer-name, and the dev symlink target. The module's human-readable name changes from "Component: Global" to "Drupal Kit" with a real description. Consumers moving from a site-localcustom_componentscopy uninstall it andcomposer require parisek/drupal-kit+drush en drupal_kit; templates and site code referencing the old namespace or service IDs must be updated. - BREAKING: package renamed
parisek/custom-components→parisek/drupal-kit— the GitHub repository moved to parisek/drupal-kit (old URLs redirect) andcomposer.jsonnamefollows, mirroring the WordPress-sideparisek/timber-kitnaming. Existing installs must update theircomposer.jsonrequire entry (and, until the package lands on Packagist, thevcsrepository URL). The Drupal module machine name stayscustom_componentsin this change; it is renamed todrupal_kitseparately before v2.0.0. - BREAKING: Composer
typechangeddrupal-custom-module→drupal-module— the package is shared infrastructure distributed to multiple projects, not a site-local module, socomposer/installersnow places it inweb/modules/contrib/instead ofweb/modules/custom/. Matches where the local dev symlink (scripts/dev-link-module.sh) and the kernel-test bootstrap already expected it. composer.jsondescriptionrewritten — from the placeholder "Provides functionality for components." to a sentence that describes the package for the Packagist search listing.- README refreshed after 1.6.0 — PHPStan badge 5 → 8, CI badge points at the renamed repo, the Services list gains the three builders (
media_array_builder,menu_tree_builder,taxonomy_tree_builder) and the_xt/__t/_nt/_nxttranslation helpers, the core-patch note mentions the new status-report warning, and Related projects linksparisek/timber-kit.
Pull Requests
- #105 — chore!: rename package to parisek/drupal-kit
- #106 — refactor!: rename module machine name to drupal_kit
Full Changelog: v1.6.0...v2.0.0
v1.6.0
What's Changed
Added
- Drupal coding standards via
drupal/coder(phpcs) — newphpcs.xml.distrunning theDrupal+DrupalPracticerulesets oversrc/,tests/and the module files;drupal/coderpromoted to an explicitrequire-deventry;composer phpcs/composer phpcbfaliases; CI'scomposer hygienejob gains a phpcs step. The initial sweep fixed all 172 pre-existing violations at the source — 82 viaphpcbf, 90 by hand (property@vardocblocks, empty/short doc comments filled with real descriptions, comment rewraps to ≤80 chars, missing@paramdefinitions, three genuinely-unused variable assignments dropped while keeping their side-effectful calls) — zero suppressions, nophpcs:ignoreanywhere. Full suite (347 tests) and PHPStan level 8 stay green. - Release automation:
release-stamp.yml+release.yml— the two-workflow pattern fromparisek/timber-kit. Stamp (manualworkflow_dispatchwith a semver input) validates the version + non-empty[Unreleased], runs the full test + PHPStan suite as a release gate, stamps the CHANGELOG, commits, pushes an annotated tag and cross-dispatches Release. Release (tag push / manual re-run / cross-dispatch) builds GitHub Release notes from the tag's CHANGELOG section + the(#N)squash-merge PR list between tags, and marks Latest only for the highest semver. Adapted for this repo:checkout@v4, SHA-pinnedsetup-php, the kernel-test prerequisites (gd/pdo_sqlite+scripts/dev-link-module.sh) inside the stamp gate, and no registry-sync step (consumers install viavcs— the pushed tag is immediately consumable, per RELEASING.md). Nothing fires without a manual dispatch or tag push. - AGENTS.md: TDD-non-negotiable + feature-flag doctrine — two sections ported from
parisek/timber-kitand adapted: (1) test-first discipline stated as doctrine (failing test first, bug fixes reproduce as regression tests, lowest-tier-first with the decision tree in CONTRIBUTING.md, pristine output under the existingfailOnRisky/failOnWarningPHPUnit flags); (2) behavior-changing features ship opt-in default-off —protected boolflags on the consumer-subclassedComponentBase/DisplayBase,$params-key opt-ins on container services, breaking changes allowed only behind such opt-ins, opinionated defaults expressed downstream indrupal-base/site projects rather than in library defaults. docs/adr/— Architecture Decision Records — Nygard-triad ADRs (Context / Decision / Consequences), numbered permanently, written sparingly (hard-to-reverse + surprising-without-context + real-trade-off, all three).docs/is git-ignored repo-wide with only theadr/subtree tracked, so scratch planning docs never enter history. Ships with ADR 0001 recording the deliberate no-composer.lockpolicy (drift-detection over reproducibility, contained by theplatform.phppin and the CI PHP matrix + hygiene job). Doctrine ported fromparisek/timber-kit.RELEASING.md— release doctrine — tag-driven flow adapted to this package's no-Packagist distribution (consumers install via avcsrepository entry, so a pushed annotated tag is immediately consumable): semver procedure, Conventional Commits → bump mapping table, a Public API surface definition specific to this package (service IDs + public methods,ComponentBase/DisplayBaseoverridables, the Twig function/filter surface, documented data shapes; container-wired constructor signatures explicitly excluded), and a Deprecation lifecycle (docblock-only@deprecated, no runtime notices in request-serving paths, ≥ one MINOR grace period, live deprecations table — currently empty). README gains a short## Releasingsection pointing at it. Ported fromparisek/timber-kitand adapted.- Status-report warning when
menu.language_tree_manipulatoris missing on a multilingual site (#90) — since the MenuTreeBuilder extraction, the language manipulator (shipped by the Drupal core patch from #2466553) is an optional dependency and menu language filtering silently no-ops when the service is absent. Newhook_requirements()runtime check incustom_components.installmakes the gap visible on/admin/reports/status: on a multilingual site it reports Available (REQUIREMENT_OK) when the service exists and aREQUIREMENT_WARNINGwith a link to the core issue when it doesn't; monolingual sites get no entry (filtering is irrelevant there). Three kernel tests pin the contract: non-runtime phases report nothing, monolingual sites report nothing, multilingual sites without the service warn with the service name in the description. - Auto-typography translation helpers
_xt/__t/_nt/_nxt(#87) — typography-aware twins of the existing_x/__/_n/_nxTwig functions. Each translates first, then pipes the result through thetypographyfilter, so editors get curly quotes / non-breaking spaces / dewidowing on translated UI strings with a one-character opt-in (_x(…)|typography→_xt(…)). Registered onTwigExtensionwithneeds_environment(thetypographyfilter is resolved from the environment at call time, so this extension stays decoupled from the siblingTypographyExtensionthat provides it) andis_safe: ['html'](mirrors the filter's own safety flag — no double-escaping of the markup it returns). Signatures match the WordPress originals 1:1 so the same templates render acrossparisek/styleguide(#21),parisek/timber-kit(#42) and Drupal:_xt($text, $context, $domain),__t($text, $domain),_nt($single, $plural, $number, $domain),_nxt($single, $plural, $number, $context, $domain). The$domainargument has no Drupal analogue (translations are keyed by langcode, not text domain), so it is accepted for cross-CMS parity and otherwise ignored;__t/_ntcarry no context (matching WP), while_xt/_nxtforward it viat()/formatPlural()options. If thetypographyfilter is absent the helpers degrade to a plain translation rather than throwing. Six unit tests assert translate-then-typography compose order, context forwarding, plural selection, the no-filter fallback, and end-to-endis_safe(no double-escaping) through a real Twig render. Drupal side of parisek/styleguide#21.
Changed
- PHPStan raised to level 8 (was 5) — the max-rigor level
parisek/timber-kitruns. The 264 pre-existing findings are grandfathered in a regeneratedphpstan-baseline.neon(dominant categories: missing param/return typehints ~137, missing iterable value types ~57 — routine follow-up, entry by entry); new code baselines nothing and is held to level 8. The 8call to undefined method object::…findings were fixed, not baselined —TaxonomyTreeBuildernow narrowsloadTree(..., TRUE)results with aninstanceof TermInterfaceguard andMenuActiveTrailResolverguardscreateInstance()results withinstanceof MenuLinkInterface— so that whole error class stays live for future typos instead of hiding in the baseline.mglaman/phpstan-drupalwas already active viaphpstan/extension-installerauto-discovery (no wiring change needed). - CI: PHP 8.3/8.4 matrix + composer-hygiene job — the test job now runs on a
fail-fast: falsematrix of PHP 8.3 and 8.4 (coverage + the ratchet threshold stay on the 8.3 floor leg only; the 8.4 leg proves the suite passes on the newer runtime before consumers hit it). A separatecomposer hygienejob runscomposer validate --strict,composer audit --abandoned=report(security advisories fail the job, abandoned transitive packages only report) andcomposer normalize --dry-run;ergebnis/composer-normalizejoinsrequire-dev+allow-pluginsandcomposer.jsonis normalized once to establish the canonical shape. Pattern ported fromparisek/timber-kittests.yml. - CI: Conventional-Commits lint on PR titles — new
commitlint.ymlworkflow (amannn/action-semantic-pull-request, SHA-pinned to the v5 line) gates every PR title against thefeat/fix/docs/chore/refactor/perf/test/ci/build/reverttaxonomy that AGENTS.md documents but nothing previously enforced. PRs squash-merge with the title as the commit subject, so the title is what the future release bump-mapping reads. Scope optional. Pattern ported fromparisek/timber-kit. composer.json:scriptsaliases +config.platform.phppin —composer test/test:unit/test:kernel/phpstanaliases so contributors and docs stop spellingvendor/bin/...paths, andconfig.platform.php: 8.3.0so dependency resolution targets the package's PHP floor even on newer dev machines (the repo deliberately ships nocomposer.lock, so everycomposer install/updatere-resolves — the pin keeps that resolution honest against the>=8.3requirement). Pattern ported fromparisek/timber-kit.- Distribution trimmed via
.gitattributesexport-ignore—composer require parisek/custom-componentspreviously shipped the full tracked tree (tests/, .github/, .ddev/, scripts/, CHANGELOG, AGENTS/CLAUDE/CONTRIBUTING, phpstan/phpunit configs) into consumers'vendor/because the repo had no tracked.gitattributes— the local one is generated bydrupal/core-composer-scaffoldand was.gitignored. Now a tracked.gitattributesexport-ignores everything development-only, so the dist archive carries just the module files (custom_components.*),src/,templates/,composer.json,LICENSE,README.md. Scaffold generation of the file is disabled viaextra.drupal-scaffold.file-mappingso it no longer collides with the tracked copy. Pattern ported fromparisek/timber-kit. - Issue references stripped from source comments — the builder docblocks (
MenuTreeBuilder,TaxonomyTreeBuilder,MediaArrayBuilder),TwigExtension::getResizer()and thecustom_components.services.ymlresizer note referenced repo issue numbers (#6,#44), v...
v1.5.0 — Coverage push to 78.42 %
Closes the v1.5.0 coverage roadmap (#55). Pushes line coverage from 53.71 % → 78.42 % (+24.71 pp) across 19 merged PRs. No new public-API features; no behaviour changes.
Headline metrics
- Line coverage: 78.42 % (1141 / 1455 statements, measured under PHP 8.3 / DDEV + xdebug).
- Tests: 282 → 336 (+54).
- CI
MIN_COVERAGEfloor raised 53 → 78.
What changed (high level)
- Tier 1 (Public API contract, #56–#59):
MediaArrayBuilder::buildSvg,TwigExtensionfour missing filters/functions,EntityHelperninegenerateMedia*/generateFile*dispatch delegates, fourEntityHelperpublic hot-paths (addCacheTags,getMenuField,getSelectFieldOptions,getSvgViewBoxDimensions). - Tier 2 (Branch coverage, #60–#63):
EntityHelperprivate dispatch + mapping helpers,Resizerpost-#44 audit + effect-builder coverage,MenuTreeBuilderrenderLinksdeep paths,MenuActiveTrailResolver+TaxonomyTreeBuilderhelper coverage. - Tier 3 (Activations + cleanups, #64–#65):
drupal/commerce+drupal/addressadded to require-dev (activating two contrib-gated tests);TaxonomyTermController+ itsentity.taxonomy_term.canonicalroute override deleted (Drupal 6/7 legacy carry-over made obsolete by core's_entity_view: 'taxonomy_term.full'since Drupal 8). - Fixed (#70):
EntityHelper::getSelectFieldOptionshonours the$langcodeparameter — a stray$config = $originalConfig->get();was overwriting the merged-with-language-overrides config. Surfaced during the #69 Copilot review. - Docs (#76): New
AGENTS.md+CLAUDE.mdshared agent-config files (mirrors theparisek/styleguidepattern). Documents the DDEV-first commands, kernel-test module-list gotchas, PR + review-thread workflow. - Metric-attribution fixes (#79–#82): Two recurring patterns identified and fixed across multiple classes — facade-as-default-class indirection and strict
@covers ::publicMethodfiltering. About 7 pp of the total move came from these without writing new tests, just adding the missing annotations / direct test files. - Final push (#83): Eight
EntityHelper::dispatchByFieldTypecase lines via per-field-typeformatFieldtests.
Per-class final state
| Class | Lines |
|---|---|
ComponentBase, all five Plugin\Filter\*, Routing\RouteSubscriber |
100 % |
Services\MenuActiveTrailResolver |
97.50 % |
TwigExtension |
96.75 % |
Twig\TypographyExtension |
94.74 % |
Services\Resizer |
90.45 % |
Services\MediaArrayBuilder |
90.30 % |
Services\MenuTreeBuilder |
89.33 % |
Services\TaxonomyTreeBuilder |
85.96 % |
Services\EntityHelper |
62.48 % |
DisplayBase |
13.33 % |
Three contrib-gated tests still skip (office_hours / geofield / webform — modules not in require-dev). Detailed Deferred to v1.6.0+ section in CHANGELOG.md.
Upgrade
composer update parisek/custom-componentsDrop-in. No code changes required on the consumer side.
v1.4.0 — quality + forward-compat hardening
Quality + forward-compat release. No behavioural changes for callers — focus is hardening of the v1.3.0 surface, static-analysis ratcheting, and closing deferred test gaps.
Highlights
- PHPStan 1.x → 2.x at level 5 with
mglaman/phpstan-drupal(#45) menu.language_tree_manipulatormade optional — no longer hard-depends on the contrib-or-patched service (#43)Resizerreduced to static utility — service registration dropped,Resizer::resizer()called directly (#44)- Deferred kernel coverage closed — remote-video URL extraction, Lottie file path, DisplayBase
__calldelegation, contrib-gated getters behindmarkTestSkipped(#47) - DDEV pinned as canonical local environment — PHP 8.3 matches CI + production;
ddev coveragecustom command bundled - README polish — 6 badges, PORTA caps, Drupal links (#50)
- Brittle entity_reference walker tests removed (#46)
Coverage
53.71% (789 / 1469 lines), measured under PHP 8.3 / DDEV with xdebug coverage driver. MIN_COVERAGE floor unchanged at 53 — v1.4.0 is hardening, not a coverage push. See CHANGELOG.md for the per-class breakdown that seeds v1.5.0 planning.
BC
None. No public-API changes. The only consumer-visible change is the documented Resizer call shape, which was already a static-style call in practice (the service had no state).
v1.3.0 — Coverage 45% → 53%
Closes #30 (meta).
Highlights
- Resizer kernel test suite (#31): first kernel-level coverage of the image-resizer service.
- EntityHelper image/file/media field-getter kernel tests (#32):
getImageField,getFileField,getMediaFieldwith cache-tag bubble assertion. - formatField polymorphic dispatch kernel tests (#34): one test per field-type branch verifies the dispatch wiring.
- ComponentBase form API kernel tests (#35): real
FormStatevalidates the build + submit cycle.
Stats
- 262 tests (+34), 1379 assertions (+403)
- Coverage: 53.03% (+7.15% over v1.2.0; floor 53)
- PHPStan: clean at level 1
- 5 PRs merged: #37, #38, #39, #40, #41
Honest accounting
v1.3.0 spec called for 65%. We landed at 53.03%. The 1:5 test:coverage ratio we observed in v1.2.0 turned out optimistic for the harder paths — Resizer's effect chains and EntityHelper's polymorphic dispatch each cost more than estimated. The remaining 12% to 65% (and the 27% to the original 80%) move to v1.4.0 + v1.5.0.
Deferred to v1.4.0
- buildRemoteVideo (oembed) + buildLottie (#33)
- DisplayBase form API
- Contrib-gated getters (getOfficeHoursField, getAddressField, getGeoField, getPriceField, getWebformField)
- MenuActiveTrailResolver remaining 50% paths
- TypographyExtension::applyTypography variants
- MenuTreeBuilder::renderLinks deep paths
v1.2.0 — Complete the test pyramid + DI cleanup
Closes #17 (meta).
Highlights
- MediaArrayBuilder kernel safety net (#18): 18 new kernel tests covering buildImage / buildFileImage / buildVideo / buildDocument / buildImageLink / buildFileImageLink / getSvgViewBoxDimensions against real Drupal media + file + image entities.
- EntityHelper field-getter kernel safety net (#19): 18 new kernel tests covering text/textarea/select/double/boolean/date/daterange/link/term/entity_reference getters.
- FilterLinks DI (#20): request_stack via ContainerFactoryPluginInterface. \Drupal::request() static call gone.
- TwigExtension DI (#21): string_translation via constructor. \Drupal::translation() static call gone.
- scripts/dev-link-module.sh (#22): single source of truth for local + CI module wiring. find -maxdepth 1 picks up any new top-level file automatically.
- Coverage floor raised to 45% (current observed: 45.88%).
Stats
- 228 tests, 976 assertions
- Coverage: 45.88% (+13.55% over v1.1.0)
- PHPStan: clean at level 1
- 6 PRs merged: #24, #25, #26, #27, #28, #29
Deferred to v1.3.0
- buildRemoteVideo kernel coverage (needs oembed bundle + network mocking)
- buildLottie + getImageField / getFileField / getMediaField kernel coverage
- Resizer kernel coverage (177-line service with 0% kernel cover)
- contrib-gated getters (getOfficeHoursField, getAddressField, getGeoField, getPriceField, getWebformField)
- 80% coverage target — requires the above