diff --git a/README.md b/README.md index 585d961..89ad502 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,14 @@ try { ## Documentation -The complete documentation is available at [altophp.com/importmap](https://altophp.com/importmap). +- [Installation](docs/installation.md): install the package and verify its requirements. +- [Getting started](docs/getting-started.md): build and render a first import map. +- [Imports](docs/imports.md): define and resolve exact, prefix, and scoped imports. +- [Packages](docs/packages.md): load and combine maps contributed by packages. +- [Output](docs/output.md): generate safe import-map and module-preload tags. +- [Caching](docs/caching.md): cache generated output without hiding map changes. +- [Errors](docs/errors.md): recover from invalid entries, files, and resolutions. +- [Complete documentation](docs/index.md): review the package scope and every guide. ## Development diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 0000000..f1e3544 --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,34 @@ +# Caching + +Alto Importmap does not include a cache layer. Cache the rendered output in the +application when rebuilding the same complete map is expensive. Derive the key +after every package map and application override has been merged. + +```php + '/assets/app.js']); +$state = json_encode($map, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); +$key = 'importmap.'.hash('sha256', $state); + +$cache = []; +$html = $cache[$key] ??= (new HtmlRenderer())->render($map); + +echo $key.PHP_EOL; +echo $html; +``` + +The key changes when the serialized imports, scopes, integrity metadata, or +their order changes. If maps come from files, package manifests, deployment +metadata, or environment-specific URLs, include their resulting map state in +the key rather than relying only on a fixed cache name. + +`ImportMap` is mutable. Calling `add()`, `addEntry()`, or `merge()` after output +has been cached does not invalidate application storage. Finish the map first, +then derive the key and render it. Cache import-map HTML and other page output +under separate keys when their preload settings differ. diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..9f0e8eb --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,23 @@ +# Errors + +Package exceptions implement `ExceptionInterface`. Catch a precise exception +when the application can recover, or catch the interface at a request, job, or +command boundary. + +| Failure | Cause | Recovery | +| --- | --- | --- | +| `InvalidEntryException` | Empty specifier, or a prefix specifier whose non-null address does not end in `/` | Supply a non-empty key and keep both sides of prefix mappings slash-terminated | +| `SpecifierNotFoundException` | No scoped, top-level, or URL-like match exists | Check spelling and add the intended mapping | +| `ResolutionFailedException` | An exact mapping deliberately contains `null` | Remove or replace the block only when the application should permit that dependency | +| `JsonException` | `fromJson()` receives invalid JSON, or output contains bytes JSON cannot encode | Correct the input and preserve valid UTF-8 data | +| `RuntimeException` | `fromFile()` cannot find or read its path | Check the application-authorized path and permissions | + +`SpecifierNotFoundException` extends `ResolutionFailedException`, so catching +the parent handles both missing and blocked imports. When scoped resolution is +unexpected, verify the referencing URL: the longest matching scope takes +priority, then resolution falls back through shorter scopes and top-level +imports. + +The package does not verify that a resolved address exists or that a browser +can fetch it. For browser failures, inspect the rendered map, document base URL, +network response, and content security policy in the consuming application. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1fca5bb..a8f016d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -28,4 +28,5 @@ The renderer produces an import-map script followed by preload links for top-lev `ImportMap` is mutable: `add()`, `addEntry()`, and `merge()` update the current map and return it for chaining. -Next, define more complete [maps](maps.md), configure [rendering](rendering.md), or resolve specifiers in PHP with the [resolver](resolution.md). +Next, define and resolve [imports](imports.md), combine [package maps](packages.md), +or configure browser [output](output.md). diff --git a/docs/imports.md b/docs/imports.md new file mode 100644 index 0000000..00513b0 --- /dev/null +++ b/docs/imports.md @@ -0,0 +1,105 @@ +# Imports + +An `ImportMap` contains three public collections: + +- `imports`: top-level `specifier => address` mappings; +- `scopes`: scope prefixes containing their own mappings; +- `integrity`: resolved addresses mapped to integrity metadata. + +Addresses may be strings or `null`. A null exact mapping deliberately blocks +resolution. + +## Add entries + +```php +add('app', '/assets/app.js', integrity: 'sha384-example') + ->add('vendor/', '/assets/vendor/') + ->add('legacy', null) + ->add('ui', '/assets/admin-ui.js', scope: '/admin/'); + +$map->addEntry( + new MapEntry('charts', '/assets/charts.js'), + scope: '/reports/', +); + +echo json_encode($map, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), "\n"; +``` + +The output is: + +```text +{ + "imports": { + "app": "\/assets\/app.js", + "vendor\/": "\/assets\/vendor\/", + "legacy": null + }, + "scopes": { + "\/admin\/": { + "ui": "\/assets\/admin-ui.js" + }, + "\/reports\/": { + "charts": "\/assets\/charts.js" + } + }, + "integrity": { + "\/assets\/app.js": "sha384-example" + } +} +``` + +`add()` is a shortcut around `addEntry()`. Both methods mutate the current map +and return it for chaining. + +An empty specifier throws `InvalidEntryException`. When a specifier ends with +`/`, its non-null address must also end with `/`; this keeps prefix resolution +well-defined. Integrity is stored by address, so it follows that exact resolved +address across top-level and scoped entries. + +## Resolve imports + +`NativeResolver` resolves an import in PHP and returns its address with nullable +integrity metadata. + +```php +resolve('app', $map); +$dependency = $resolver->resolve('vendor/router.js', $map); +$scoped = $resolver->resolve('ui', $map, '/admin/dashboard.js'); + +echo $global['url'].PHP_EOL; +echo $global['integrity'].PHP_EOL; +echo $dependency['url'].PHP_EOL; +echo $scoped['url'].PHP_EOL; +``` + +The output is: + +```text +/assets/app.js +sha384-example +/assets/vendor/router.js +/assets/admin-ui.js +``` + +Resolution checks matching scopes from longest to shortest, then top-level +imports, then passes through absolute URLs and absolute or relative paths. An +exact match wins over a prefix, and the longest matching prefix wins. The PHP +resolver does not fetch a module, resolve a relative address against a browser +document, or prove that an address exists. diff --git a/docs/index.md b/docs/index.md index 28996c5..4f07b59 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,16 +1,26 @@ # Alto Importmap -Alto Importmap builds, loads, combines, renders, and resolves JavaScript import maps in PHP. It supports top-level imports, scopes, integrity metadata, null mappings, and module preloads. +Alto Importmap builds, combines, renders, and resolves JavaScript import maps +in PHP. It supports exact and prefix imports, scopes, integrity metadata, +blocked entries, and module preloads. -## Introduction +```php +use Alto\ImportMap\ImportMap; -- [Installation](installation.md) installs the package and lists its requirements. -- [Getting started](getting-started.md) builds and renders a first map. +$map = new ImportMap(); +$map->add('app', '/assets/app.js'); +``` -## Import maps +## Documentation -- [Maps](maps.md) covers entries, scopes, integrity metadata, JSON, and merging. -- [Rendering](rendering.md) generates safe import-map and module-preload tags. -- [Resolution](resolution.md) resolves exact, prefix, scoped, and URL-like specifiers. +- [Installation](installation.md): install the package and verify its requirements. +- [Getting started](getting-started.md): build and render a first import map. +- [Imports](imports.md): define and resolve exact, prefix, and scoped imports. +- [Packages](packages.md): load and combine maps contributed by packages. +- [Output](output.md): generate safe import-map and module-preload tags. +- [Caching](caching.md): cache generated output without hiding map changes. +- [Errors](errors.md): recover from invalid entries, files, and resolutions. -The package represents import maps and returns HTML or resolution data. It does not download JavaScript modules or write configuration files. +The package represents import maps and returns HTML or resolution data. It does +not download JavaScript modules, verify remote content, or write configuration +files. diff --git a/docs/maps.md b/docs/maps.md deleted file mode 100644 index 7a60553..0000000 --- a/docs/maps.md +++ /dev/null @@ -1,92 +0,0 @@ -# Maps - -An `ImportMap` contains three public collections: - -- `imports`: top-level `specifier => address` mappings. -- `scopes`: scope prefixes containing their own mappings. -- `integrity`: resolved addresses mapped to integrity metadata. - -Addresses may be strings or `null`. A null exact mapping explicitly blocks resolution. - -## Add entries - -```php -add('app', '/assets/app.js', integrity: 'sha384-example') - ->add('vendor/', '/assets/vendor/') - ->add('legacy', null) - ->add('ui', '/assets/admin-ui.js', scope: '/admin/'); - -$map->addEntry( - new MapEntry('charts', '/assets/charts.js'), - scope: '/reports/', -); - -echo json_encode($map, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); -``` - -`add(string $specifier, ?string $address, ?string $integrity = null, ?string $scope = null)` is a shortcut around `addEntry(MapEntry $entry, ?string $scope = null)`. - -An empty specifier throws `InvalidEntryException`. When a specifier ends with `/`, its non-null address must also end with `/`; this keeps prefix resolution well-defined. - -Integrity is stored by address, not by specifier. Adding an integrity value to a scoped or top-level entry therefore makes it available whenever that exact address is resolved. - -## Construct or load a map - -The constructor accepts the three collections directly: - -```php - '/assets/app.js'], - scopes: ['/admin/' => ['app' => '/assets/admin.js']], - integrity: ['/assets/app.js' => 'sha384-example'], -); - -$fromJson = ImportMap::fromJson( - '{"imports":{"editor":"/assets/editor.js"}}', -); - -echo count($map).' top-level import(s)'.PHP_EOL; -foreach ($fromJson as $specifier => $address) { - echo $specifier.' -> '.$address.PHP_EOL; -} -``` - -`fromJson()` throws `JsonException` for invalid JSON. `fromFile()` reads the same structure and throws `RuntimeException` when the path does not exist or cannot be read. - -`count()` and iteration cover top-level imports only. `json_encode()` omits empty sections, except that an entirely empty map currently serializes as `[]`. - -## Merge maps - -`merge(ImportMap $other): self` mutates the receiving map. Entries from the other map replace entries with the same key. - -```php - '/assets/app.js']); -$feature = new ImportMap(imports: ['charts' => '/assets/charts.js']); - -$application->merge($feature); - -echo json_encode($application, JSON_THROW_ON_ERROR); -``` - -Top-level imports and integrity entries merge individually. A scope from the other map replaces the complete scope with the same prefix; merge the entries yourself first when two maps must contribute to one scope. diff --git a/docs/output.md b/docs/output.md new file mode 100644 index 0000000..68ed0a0 --- /dev/null +++ b/docs/output.md @@ -0,0 +1,43 @@ +# Output + +`HtmlRenderer::render(ImportMap $map, bool $preload = true): string` produces an +import-map script and, by default, module-preload links. + +```php +add('app', '/assets/app.js', integrity: 'sha384-example') + ->add('blocked', null) + ->add('admin', '/assets/admin.js', scope: '/admin/'); + +$html = (new HtmlRenderer())->render($map, preload: true); +echo $html; +``` + +The output is: + +```html + + +``` + +The JSON includes imports, scopes, null mappings, and integrity metadata. The +renderer uses the `JSON_HEX_*` flags so map content cannot close the script +element, and escapes preload addresses for the `href` attribute. + +Preload links are generated only for non-null top-level imports. Scoped entries +are not preloaded, and integrity metadata is not copied to an HTML `integrity` +attribute. Pass `preload: false` when the application uses another preload +strategy. + +Insert the generated map before scripts that import its specifiers. A mapping +does not publish or download its target, and the package does not validate an +integrity hash against remote content. Custom renderers implement +`RendererInterface::render(ImportMap $map, bool $preload = true): string`. diff --git a/docs/packages.md b/docs/packages.md new file mode 100644 index 0000000..4edc3db --- /dev/null +++ b/docs/packages.md @@ -0,0 +1,62 @@ +# Packages + +Applications can build one map per package or feature, then merge those maps +into the map rendered for the page. A map can also start from JSON supplied by a +package manifest or configuration file. + +## Load a map + +```php + $address) { + echo $specifier.' -> '.$address.PHP_EOL; +} +``` + +The output is: + +```text +1 top-level import(s) +editor -> /assets/editor.js +``` + +`fromFile($path)` reads the same JSON structure. `count()` and iteration cover +top-level imports only; scopes and integrity remain available through their +public collections. + +## Combine packages + +```php + '/assets/app.js']); +$feature = new ImportMap(imports: ['charts' => '/assets/charts.js']); + +$application->merge($feature); +echo json_encode($application, JSON_THROW_ON_ERROR), "\n"; +``` + +The output is: + +```text +{"imports":{"app":"\/assets\/app.js","charts":"\/assets\/charts.js"}} +``` + +`merge()` mutates the receiving map. Top-level imports and integrity entries +merge by key, with the incoming value winning. An incoming scope replaces the +complete scope with the same prefix; merge its entries first when several +packages must contribute to one scope. diff --git a/docs/rendering.md b/docs/rendering.md deleted file mode 100644 index 6f4477b..0000000 --- a/docs/rendering.md +++ /dev/null @@ -1,31 +0,0 @@ -# Rendering - -`HtmlRenderer::render(ImportMap $map, bool $preload = true): string` produces the import-map script and, by default, module-preload links. - -```php -add('app', '/assets/app.js', integrity: 'sha384-example') - ->add('blocked', null) - ->add('admin', '/assets/admin.js', scope: '/admin/'); - -$html = (new HtmlRenderer())->render($map, preload: true); -echo $html; -``` - -The JSON includes imports, scopes, null mappings, and integrity metadata. Encoding uses `JSON_THROW_ON_ERROR`, unescaped slashes, and the `JSON_HEX_*` flags so map content cannot close the script element. - -Preload links are generated only for non-null top-level imports. Scoped entries are not preloaded, and integrity metadata is not added as an HTML `integrity` attribute. Disable every preload link with `preload: false` when the application needs a different strategy. - -Addresses in preload links are escaped with `htmlspecialchars()` before insertion into the `href` attribute. - -Custom renderers implement: - -`RendererInterface::render(ImportMap $map, bool $preload = true): string` diff --git a/docs/resolution.md b/docs/resolution.md deleted file mode 100644 index cd92107..0000000 --- a/docs/resolution.md +++ /dev/null @@ -1,63 +0,0 @@ -# Resolution - -`NativeResolver` resolves a module specifier against an `ImportMap` and returns its address with any integrity metadata. - -```php -add('app', '/assets/app.js', integrity: 'sha384-example') - ->add('vendor/', '/assets/vendor/') - ->add('app', '/assets/admin.js', scope: '/admin/'); - -$resolver = new NativeResolver(); - -$global = $resolver->resolve('app', $map); -$dependency = $resolver->resolve('vendor/router.js', $map); -$scoped = $resolver->resolve('app', $map, '/admin/dashboard.js'); - -echo $global['url'].PHP_EOL; -echo $global['integrity'].PHP_EOL; -echo $dependency['url'].PHP_EOL; -echo $scoped['url'].PHP_EOL; -``` - -`resolve(string $specifier, ImportMap $map, ?string $referencingUrl = null): array` returns an array with `url` and nullable `integrity` keys. - -Resolution proceeds in this order: - -1. Matching scopes, from the longest scope prefix to the shortest. -2. Top-level imports. -3. Passthrough for absolute URLs, root-relative paths, `./` paths, and `../` paths. - -Within a scope or the top-level map, an exact match takes priority. Prefix entries ending with `/` use the longest matching prefix and append the remainder of the specifier to the mapped address. When a matching scope does not contain the specifier, resolution continues to shorter scopes and then the top-level imports. - -## Failures and blocked entries - -```php - null]); - -try { - (new NativeResolver())->resolve('legacy', $map); -} catch (ResolutionFailedException $exception) { - echo $exception->getMessage(); -} -``` - -An exact null mapping throws `ResolutionFailedException`. An unmatched bare specifier throws its `SpecifierNotFoundException` subclass. Catch `ResolutionFailedException` when both outcomes require the same handling. - -Custom resolvers implement the same `ResolverInterface::resolve()` contract and may throw package exceptions implementing `ExceptionInterface`.