Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,60 @@ All notable changes to `laravel-package-toolkit` will be documented in this file
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [2.4.2] - 2026-08-10

### Added

- **The three tag directives take no package name.** `@packageAssets`, `@packageStyles` and
`@packageScripts` now render every package that declared entries, in the order their providers
handed them over, and the short name is optional rather than required. A layout that names its
packages is a layout that has to be edited every time one is installed or removed, in every file
carrying the line — and `package:discover` does not close that gap, because it discovers
*providers* while the template still names packages by hand. The aggregate is the form an
application's layout wants: one line that keeps saying everything.
Stylesheets lead across the whole set rather than within each package, since the aggregate renders
one document's `<head>` and a package whose provider booted third is no reason for its stylesheet
to land behind the second package's scripts — within each of the two halves, that is, since the
Vite keys are still collected into a single call so the preloads remain one set, and that block is
emitted whole and first. Everything else stays per entry — each package's `classic()`, its
attributes, its own Vite resolution. `@packageAssetUrl` keeps both arguments; it answers
with one URL, and there is no URL of every package.

- **`hasAssetFallback()` — where to serve a shipped file from when nothing is published.** An entry
that resolved to nothing rendered no tag at all. That is right for an entry the application chose
not to build, and wrong for the entry that is the package's only copy: where `public/` cannot be
written — a read-only container, Vapor, shared hosting — the page lost its stylesheet or its
behaviour with nothing in the markup, the log or the console to say why, on exactly the
deployments least likely to go looking. The documented answer was to call `PublishedAssets::url()`
and compose a tag by hand, which is the pre-2.4.0 pattern the renderer exists to remove.
A package that also serves its assets from a route of its own now points at it and keeps the tag,
with `type="module"` or the `defer` that `classic()` implies, its declared attributes,
`data-navigate-track` and the application's CSP nonce still on it. The resolver
(`fn (string $file, string $package): ?string`) is reached only after both the mirror and
`public/vendor/{short-name}` came back empty, so a normal deployment never calls it, and it owns
the whole URL it returns, cache-busting query string included — the `?id=` the renderer appends
elsewhere is the published copy's mtime, and the point of being there is that there is none.
Returning `null` drops the tag as before, and `resolution()` gained a `fallback` state so a
deployment serving from the route is distinguishable from one serving nothing.

Both are additive: `@packageAssets('blog')` renders byte for byte what it did, `PackageAssets::declare()`
took a new parameter with a default, and a package that declares no fallback behaves exactly as before.

### Fixed

- **`resolution()` no longer reports `shipped` for a mirrored package whose copy can never be
written.** The mirrored arm asserted it outright, ahead of every arm that checks anything, so an
unwritable `public/` — a read-only container, Vapor, shared hosting — reported every entry as
served from `public/` while the page rendered nothing at all, or, once `hasAssetFallback()`
existed, rendered the fallback. That left `fallback` unreachable for any mirrored package, which
is to say for the default, and it left the report wrong in precisely the production condition it
was added to expose. A copy already published now earns `shipped` first; a mirrored entry without
one earns it only where the mirror could still create it, asked of the nearest existing ancestor
of `public/vendor/{short-name}` so a read-only `public/vendor` under a writable `public/` is not
taken for a writable one. The lazy mirror is unaffected — a fresh install, where `about` runs
before any request has published anything, still reports `shipped` — and nothing is published to
find out, as before.

## [2.4.1] - 2026-08-10

### Added
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,49 @@ are checked at registration, so a typo throws where it was declared. `.js` rende
`type="module"`; a shipped IIFE bundle says so with
`Asset::make('js/index.js')->classic()`.

#### Naming no package renders every one

Since 2.4.2 the short name is optional on the three tag directives — the form an application's own
layout wants, since a layout that names its packages is one that has to be edited every time a
package is installed or removed:

```blade
<head>
@packageStyles
</head>
<body>
@packageScripts
</body>
```

Every package that declared entries renders, in the order their providers handed them over.
Stylesheets lead across the whole set rather than within each package — within each of the two
halves, that is: what the application's Vite build covers is emitted as one block so its preloads
stay a single set, and that block leads. Everything else stays per entry: each package's
`classic()`, its attributes, its own Vite resolution. `@packageAssetUrl` still takes both
arguments — it answers with one URL, and there is no URL of every package.

#### Keeping the tag when nothing is published

An entry that resolves to nothing renders no tag. That is right for an entry the application chose
not to build, and wrong for the entry that is your package's only copy: where `public/` cannot be
written — a read-only container, Vapor, shared hosting — the page loses its stylesheet or its
behaviour with nothing to say why. If your package also serves its assets from a route of its own,
say so and the tag survives:

```php
$packager
->hasAssets(entries: ['js/index.js'])
->hasAssetFallback(fn (string $file): string => route('my-package.asset', ['file' => $file]));
```

The resolver is reached only after both the mirror and `public/vendor/my-package` came back empty,
so a normal deployment never calls it, and it owns the whole URL including any cache-busting query
string — the `?id=` elsewhere is the published copy's mtime, and the point of being here is that
there is none. Returning `null` drops the tag as before. What declaring it buys is the tag itself:
`type="module"` or the `defer` that `classic()` implies, your attributes,
`data-navigate-track="reload"` and the application's CSP nonce.

#### Naming nothing discovers them

Name no entries and the asset directory answers for itself, the way `hasRoutes()` and `hasViews()`
Expand Down Expand Up @@ -958,7 +1001,8 @@ fine, which is what keeps it in codebases — here is what it leaves out, almost
- **`url()` returns `?string`.** Where `public/` cannot be written and nothing was published before,
this renders `src=""` — which a browser resolves against the current page and fetches the HTML as
a script. Nothing throws, nothing 404s, and the page is broken. The directives emit no tag at all
in that situation.
in that situation, or the one
[`hasAssetFallback()`](#keeping-the-tag-when-nothing-is-published) points at.
- **No `type="module"`**, so a Vite bundle's top-level `import` is a syntax error.
- **No `data-navigate-track="reload"`**, which makes `?id=<mtime>` a query string nobody reads:
Livewire has no reason to full-page-reload a `wire:navigate` visit, so an upgrade lands as new
Expand Down
4 changes: 3 additions & 1 deletion ai/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,11 @@ $packager->hasOptimizeCommands(
- **Do not build a tag around it in a template.** `<script src="{{ app(PublishedAssets::class)->url(...) }}">` is the pre-2.4.0 pattern and is wrong now: `url()` is nullable, so an unwritable `public/` renders `src=""` — a browser resolves that against the current page and fetches its HTML as a script, with no throw and no 404 — and the tag carries no `type="module"`, no `data-navigate-track`, no CSP nonce and no Vite resolution. Declare the file in `entries:` and render `@packageScripts` / `@packageStyles`, or take the bare URL from `@packageAssetUrl` / `app(PackageAssets::class)->url($shortName, $entryKey)`, which keys off the entry rather than an absolute path. `PublishedAssets` direct is for what is *not* a declared entry — an image or font the template composes itself, an application-registered asset — and for `isStale()`.
- **Omitting `entries:` discovers them** (2.4.1): the stylesheets and scripts directly inside the asset directory and its `css/` and `js/` subdirectories become entries, sorted alphabetically. It follows whatever directory `hasAssets()` was given, not `dist` literally. Extensions are an allowlist (`css`, `scss`, `sass`, `less`, `styl`, `pcss`, `js`, `mjs`, `cjs`), so maps/fonts/images/`manifest.json` are skipped, and it is **not** recursive — a code-split build's chunk directory (Vite's `assets/`) is deliberately left alone, because a chunk is imported by an entry point rather than loaded beside it. Naming any entry replaces discovery outright; the two do not merge. Name them explicitly for a code-split build, for an IIFE bundle (a discovered script is always a module), or to avoid the directory listing that runs once per boot.
- `entries:` are paths inside the asset directory, validated at declaration. Declaring any registers four global Blade directives taking the package short name: `@packageAssets($package, ...$only)`, `@packageStyles(…)`, `@packageScripts(…)` and `@packageAssetUrl($package, $entry)` (URL only). `.js` renders as `type="module"` with `data-navigate-track="reload"`; pass `Support\Asset::make('js/x.js')->classic()` for an IIFE/UMD bundle, or `->attributes([...])` / `->asStylesheet()` for the rest.
- **The short name is optional since 2.4.2** on the three tag directives: `@packageAssets` / `@packageStyles` / `@packageScripts` with no argument render every package that declared entries, in provider-boot order, with stylesheets leading across the whole set rather than per package — within each of the two halves, that is: what the application built is emitted as one Vite block (preloads, stylesheets, scripts, in Vite's order) and that block leads, so a built script precedes a stylesheet that fell back to the shipped copy. Prefer it in an application layout — a layout that names its packages has to be edited whenever one is installed or removed, and `package:discover` does not help (it discovers providers, not template lines). `@packageAssetUrl` still takes both arguments; there is no URL of every package.
- `hasAssetFallback(Closure $resolver): static` (2.4.2) — where to serve a shipped file from when nothing is published, `fn (string $file, string $package): ?string`. Without it, an entry that resolves to nothing renders **no tag at all**, which is right for an entry the application declined to build and wrong for the one that is the package's only copy: an unwritable `public/` (read-only container, Vapor, shared hosting) then costs a page its stylesheet or its behaviour, silently. A package serving its assets from a route of its own points at it here and keeps the tag *with* `type="module"`/`classic()`+`defer`, its attributes, `data-navigate-track` and the CSP nonce — which is the whole reason to declare it rather than hand-write a `<script>` beside the directive. Reached only after the mirror and `public/vendor/{shortName}` both came back empty, so a normal deployment never calls it; the resolver owns the entire URL including any cache-buster, and returning `null` drops the tag as before. Throws `PackageConfigurationException` if declared before `hasAssets()`.
- `hasViteAssets(array $entries, ?string $base = null): static` — declare sources the **consuming application's** Vite build can compile (`'resources/js/blog.js' => 'js/blog.js'` maps a source to the shipped file it stands in for; a plain list declares sources with no shipped copy). The toolkit builds nothing and ships no Vite config; this only makes the application's build a first-class way to serve the package. Each entry resolves per request: dev server while `npm run dev` runs → the application's manifest key `{base}/{source}` → the shipped file via the mirror. `$base` is derived from the package's location under `base_path()` (`vendor/acme/blog`); pass it explicitly for a symlinked path repository, where nothing can be derived. A miss falls back silently rather than throwing.
- Declaring the same shipped file in both calls is intended, not a duplicate: the later entry replaces the earlier one and renders once, in the position first declared. Since 2.4.1 the replacement **inherits the earlier entry's presentation** — `classic()` is sticky, attributes merge with the newer winning a collision, and an explicit `asStylesheet()`/`asScript()` on the replacement stands. So `Asset::make('js/blog.js')->classic()` plus the `hasViteAssets()` shorthand for the same file keeps the shipped bundle classic on the fallback path; before 2.4.1 it silently became a module and the IIFE's globals stopped reaching `window`.
- Diagnostics: falling back is silent by design, so `app(PackageAssets::class)->resolution($shortName)` names how each entry resolves right now (`dev server` / `application build` / `shipped` / `not published` / `unresolved`) without writing anything. A package that called `hasAbout()` and declared Vite sources also gets an `Assets` line in its `php artisan about` section. Use it when an application swears it added the input but the shipped file is still being served.
- Diagnostics: falling back is silent by design, so `app(PackageAssets::class)->resolution($shortName)` names how each entry resolves right now (`dev server` / `application build` / `shipped` / `fallback` / `not published` / `unresolved`) without writing anything. A package that called `hasAbout()` and declared Vite sources also gets an `Assets` line in its `php artisan about` section. Use it when an application swears it added the input but the shipped file is still being served. `shipped` for a mirrored entry means the published copy exists *or* the mirror could still make it — since 2.4.2 that second half is checked (nearest existing ancestor of `public/vendor/{shortName}` is writable) rather than assumed, so an unwritable `public/` reports `fallback` or `not published` instead of a healthy-looking `shipped`. A fresh install, where nothing has been published yet because the mirror is lazy, still reports `shipped`.
- CSP: `Vite::useCspNonce()` nonces are carried onto the tags the toolkit renders itself, so the shipped-file path is not blocked while the application-built path loads. An entry's own `nonce` attribute wins.
- Under a long-lived worker the singleton outlives the request it was scoped to, so call `app(PublishedAssets::class)->flush()` from the framework's request-terminated hook — otherwise the memo survives a deploy: the mirror runs at most once per worker boot, and every URL keeps the `?id=` of the release the worker started on. `flush()` keeps the declared directories; it only forgets the resolved URLs and the sync marks.

Expand Down
13 changes: 8 additions & 5 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,19 @@ The `$namespace` argument of `hasViews()` is [not currently applied](/views#the-
|---|---|
| `hasAssets(string $directory = 'dist', bool $mirror = true, array $entries = [])` | `static` |
| `hasViteAssets(array $entries, ?string $base = null)` | `static` |
| `hasAssetFallback(Closure $resolver)` | `static` |
| `isAssetable()` | `bool` |
| `assetDirectory()` | `string` |
| `mirrorsAssets()` | `bool` |
| `hasAssetEntries()` | `bool` |
| `assetEntries()` | `Asset[]` |
| `viteBase()` | `?string` |
| `assetFallback()` | `?Closure` |

Blade directives, registered once when any package declares entries:
`@packageAssets($package, ...$only)` · `@packageStyles(…)` · `@packageScripts(…)` ·
`@packageAssetUrl($package, $entry)` (URL only).
`@packageAssets(?$package, ...$only)` · `@packageStyles(…)` · `@packageScripts(…)` — naming no
package renders every package that declared entries — and `@packageAssetUrl($package, $entry)`
(URL only, both arguments required).

### Middleware

Expand Down Expand Up @@ -335,12 +338,12 @@ The renderer behind the directives; a container singleton shared by every packag

| Method | Returns | Notes |
|---|---|---|
| `declare(string $package, string $directory, array $entries, ?string $base, bool $mirrored)` | `void` | called by the provider |
| `tags(string $package, string ...$only)` | `HtmlString` | application build first, then stylesheets, then scripts |
| `declare(string $package, string $directory, array $entries, ?string $base, bool $mirrored, ?Closure $fallback = null)` | `void` | called by the provider |
| `tags(?string $package = null, string ...$only)` | `HtmlString` | application build first, then stylesheets, then scripts; every package when none is named |
| `styles(…)` / `scripts(…)` | `HtmlString` | same, filtered |
| `url(string $package, string $entry)` | `?string` | one URL, `null` when nothing resolves |
| `declared(string $package)` | `bool` | |
| `resolution(string $package)` | `array` | entry => `dev server` / `application build` / `shipped` / `not published` / `unresolved`; writes nothing |
| `resolution(string $package)` | `array` | entry => `dev server` / `application build` / `shipped` / `fallback` / `not published` / `unresolved`; writes nothing |

## `Support\PublishedAssets`

Expand Down
Loading
Loading