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`.