CtrlK
BlogDocsLog inGet started
Tessl Logo

adobe/stardust

Redesign an existing website to make it better. Built on top of impeccable.

71

Quality

89%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

asset-bundling.mdskills/migrate/reference/

Asset bundling

How migrate makes stardust/migrated/ a self-contained, zip-and-deploy bundle: every asset referenced by a migrated page is copied into the bundle and every reference in HTML/CSS is rewritten to point at the bundled copy. Run at the end of the per-page render (Phase 2) so it sees the final HTML.

The contract this reference documents is consumed by downstream tooling — see skills/stardust/reference/migrate-output-format.md for the output-format guarantee and the state.json.migrate block.

The reference implementation lives in the Wasatch project at paolomoz/wasatch (scripts/migrate.mjs). It is intentionally narrower than this contract — flat output, one prefix, one project — and is cited as a worked example, not the authoritative shape. The plugin generalises per § Edge cases.


Why the bundle must be self-contained

Before this phase existed, migrate left HTML files in stardust/migrated/ whose src/href attributes pointed at ../current/assets/<subpath> — paths outside the migrated tree. That breaks the natural zip-and-deploy workflow:

  • cd stardust/migrated && zip -r out.zip . produces a zip that 404s on every image when extracted to any host.
  • Downstream tools must know stardust's internal folder layout to locate the assets, coupling them to stardust's project shape.
  • The migrated tree only renders correctly when it co-exists with stardust/current/. The moment the tree leaves the project, it breaks.

Asset bundling makes the migrated tree the only directory a downstream consumer needs. Network-dependent CDN URLs (Google Fonts CSS, jsDelivr-served JS libraries) stay external by design — see § Out of scope at the bottom of this file.

When asset bundling runs

Asset bundling is the last per-page step in migrate Phase 2, after canon/module/content rendering and after <head> metadata composition but before the page is written:

  1. The rendered HTML is held in a string.
  2. Asset bundling scans the string for asset references (§ Detection), copies the unique referenced assets into stardust/migrated/assets/<subpath> (§ Copy), and rewrites the string so references point at the bundled copy (§ Rewrite).
  3. The rewritten string is written to the page's output path.

Cross-page deduplication uses a module-level Set<string> of already-bundled subpaths kept for the lifetime of the migrate run. A 100-page site with a shared header image bundles that image exactly once.

Detection

Migrate scans the final HTML string for asset references in six shapes. Every shape is detected independently; a reference matching more than one shape is handled by the most specific matcher first.

1. src and href single-URL attributes

(src|href)\s*=\s*["']([^"']+)["']

The capture group 2 is the URL. Filter to URLs whose normalised form starts with a known asset prefix (§ Prefix resolution). HTML href is intentionally included so that <link rel="icon" href="...">, <link rel="stylesheet" href="...">, etc. are also handled.

A href matching an asset prefix is rewritten; a href matching a page link is left for the link-rewrite pass to handle. The two passes are orthogonal because page links never share the asset prefix set.

2. srcset multi-URL attributes

srcset\s*=\s*["']([^"']+)["']

The value is a comma-separated list of URL [descriptor] entries. Each URL is rewritten independently; the descriptor (1x, 2x, 768w, etc.) is preserved verbatim. Whitespace between entries is preserved.

<!-- before -->
<img srcset="../current/assets/hero-1x.jpg 1x,
             ../current/assets/hero-2x.jpg 2x"
     src="../current/assets/hero-1x.jpg">

<!-- after -->
<img srcset="/assets/hero-1x.jpg 1x,
             /assets/hero-2x.jpg 2x"
     src="/assets/hero-1x.jpg">

<picture><source srcset="..."> is the same shape and handled by the same matcher.

3. Inline style="...url(...)..."

style\s*=\s*["']([^"']*url\([^)]+\)[^"']*)["']

The attribute value can carry multiple url() declarations (e.g., background-image: url(a.jpg), url(b.jpg)); each is rewritten independently. The matcher uses the generic CSS-url() sub-matcher (§ 4) once a style="…" candidate is found.

4. url(...) inside <style> blocks (and external CSS)

url\(\s*(["']?)([^)\s"']+)\1\s*\)

The capture group 2 is the URL; capture group 1 is the optional quote so the rewrite preserves quoting style.

Applied to every <style>...</style> block's content. Also applied to any external CSS file that lands in stardust/migrated/ (font-face stylesheets generated by prepare-migration --self-host-fonts are the common case; a project that emits a hand-authored CSS file in migrate output also benefits).

URLs inside CSS may be relative to the CSS file's location, not the project root. The bundler resolves them per § Prefix resolution; a url(./foo.jpg) inside a CSS at stardust/migrated/assets/fonts/inter.css resolves to stardust/migrated/assets/fonts/foo.jpg, then is rewritten to the bundled path.

5. @font-face declarations

@font-face { src: url(...) format(...), url(...) format(...) } is covered by the generic CSS-url() matcher (§ 4). Each url(...) in the src: list is rewritten independently; the format(...) hint is preserved verbatim.

6. JSON-LD and meta content attributes

<meta property="og:image" content="..."> and JSON-LD image, logo, thumbnailUrl, contentUrl fields are absolute-URL references by spec (search engines fetch them by URL). They are NOT rewritten to relative paths — the state.json.site.deployUrl strategy from metadata-and-jsonld.md already resolves them to the deploy host. Asset bundling skips these.

Exception: a project with deployUrl unset and a default OG image stored under stardust/current/assets/ — bundling copies the file into migrated/assets/og-default.jpg and leaves the absolute URL alone (so the metadata still parses; the file is also present for hosts that proxy it).

Prefix resolution

Migrate accepts asset-root prefixes — strings that mark a URL as an asset reference rather than a page link. A reference is bundled if and only if one prefix matches.

Default prefix set:

PrefixWhen it appears
../current/assets/Prototype/migrate-internal references (the Wasatch shape)
/assets/Root-relative asset paths authored by the prototype
assets/ and ./assets/Depth-0 emitted form (home page re-runs)
../assets/, ../../assets/, ../../../assets/Depth-N emitted form (nested page re-runs)

Projects may extend the set via DESIGN.json.extensions.canon.assetPrefixes[]:

"extensions": {
  "canon": {
    "assetPrefixes": ["/static/", "/img/", "../current/uploads/"]
  }
}

Resolution algorithm:

  1. Take the candidate URL.
  2. Skip if it is a scheme-bearing URL (http://, https://, data:, mailto:, tel:, javascript:, #).
  3. For each prefix (longest first), check url.startsWith(prefix).
  4. On match, the subpath is url.slice(prefix.length).
  5. Resolve <project>/stardust/current/assets/<subpath> to the source file path.
  6. If the source file does not exist, log a missing-asset warning (§ Missing assets) and skip the copy. The HTML reference is still rewritten — the bundle will 404 on this asset, which is the failure surface the warning calls out.

The longest-prefix-first ordering matters: with ["/assets/", "/assets/icons/"] declared, a URL of /assets/icons/x.svg must match the more specific prefix so the subpath is x.svg rather than icons/x.svg. The default set is free of overlap; projects extending it should keep their prefixes non-overlapping.

Copy

For each unique subpath the detection phase surfaced:

  1. Check the module-level Set<string> bundledAssets — skip if already copied this run.
  2. Compute src = <project>/stardust/current/assets/<subpath> and dst = <project>/stardust/migrated/assets/<subpath>.
  3. fs.access(src) — if it throws, log per § Missing assets and continue.
  4. fs.mkdir(path.dirname(dst), { recursive: true }).
  5. fs.copyFile(src, dst).
  6. bundledAssets.add(subpath).

The subpath structure under current/assets/ is preserved verbatim under migrated/assets/. A source at stardust/current/assets/generated/hero-4x5.jpg lands at stardust/migrated/assets/generated/hero-4x5.jpg. Downstream HTML references the bundled path with the same subpath, just with the prefix swapped to /assets/.

Rewrite

Once copy is done, every detected reference is rewritten in the HTML string. The rewrite produces a depth-aware relative path of the form <prefix>assets/<subpath>, where <prefix> is computed from the page's outputPath per migration-procedure.md § Reference shape:

depth  = segments(outputPath) - 1
prefix = depth === 0 ? "./" : "../".repeat(depth)

Worked examples:

Page outputPathDepthPrefixAsset reference
index.html0././assets/hero.jpg
beers/index.html1../../assets/hero.jpg
about-us/history.html1../../assets/hero.jpg
about-us/team/index.html2../../../../assets/hero.jpg

Depth-aware relative was chosen because it makes the bundle truly portable: file://, host-root deploy, and any-subpath deploy all work without rewriting at deploy time. Root-relative /assets/<subpath> works only on a webserver that serves the bundle at the host root — it 404s on file:// and at any subpath.

The Wasatch reference uses flat relative (assets/<subpath>, no ./ prefix) because every HTML file lives at the bundle root. The plugin's nested layout requires the explicit depth-aware prefix; the same algorithm reduces to Wasatch's shape when every page has depth 0.

The rewrite is idempotent across re-runs: a reference already shaped as <prefix>assets/<subpath> passes back through detection by matching the bare assets/ prefix (depth-aware relative paths all end in assets/<subpath>); the bundler extracts the subpath, copies if not already bundled, and emits the same string. The asset-prefix set (§ Prefix resolution) explicitly includes assets/, ./assets/, and ../assets/ for this reason.

Strict byte-identity across re-runs (modulo provenance timestamp) holds because depth is a pure function of outputPath and outputPath is stable across runs.

State.json migrate block

Migrate writes a new top-level migrate block to state.json recording the bundle composition. See skills/stardust/reference/migrate-output-format.md § State.json contract for the canonical shape; the per-page assetsBundled count comes from this phase.

Edge cases

Missing assets

When a referenced subpath has no file under stardust/current/assets/, the bundler:

  • Logs a one-line warning at WARN level (printed in the run summary's Missing assets: block).
  • Records the missing reference in the page's _meta.json#migrationDecisions[] with kind: "asset-missing", the subpath, and the referencing attribute/selector.
  • Still rewrites the HTML reference to /assets/<subpath> so the bundle is internally consistent; the missing file is a deploy-time 404, not a migrate-time failure.

Missing assets are warnings, not errors. They surface in the migrate report so the user can either re-extract or accept the gap.

Cross-page deduplication

The bundledAssets: Set<string> is module-scoped, lifetime = one $stardust migrate run. A shared header logo referenced by 50 pages is copied once. The per-page assetsBundled count in _meta.json reflects references, not copies — useful for understanding per-page asset footprint; state.json.migrate.totalAssetsBundled reflects unique copies.

A page that comes in via the idempotent skip (sha-matched, unchanged) does NOT participate in the bundling pass — its already-emitted HTML is left untouched, so its asset references are assumed to be already-rewritten by the prior run. The cross- page dedup set is seeded from state.json.migrate.bundledAssets at the start of each run so a re-run that re-renders only one page still knows what's already on disk.

Idempotency

Re-running $stardust migrate with no source changes produces byte-identical bundled HTML modulo the migrate-provenance timestamp. The provenance block carries writtenAt, which changes on every render and prevents strict byte-identity. The idempotent skip per migration-procedure.md § Idempotent skip guarantees that unchanged pages aren't re-rendered at all — their HTML is not rewritten, so no timestamp shifts.

Projects that need byte-identical re-runs (CI deployment fingerprinting, content-addressable hosting) can pin the timestamp via --pin-timestamp <ISO8601>.

Stale asset cleanup

A migrated bundle can accumulate stale assets across multiple runs — a prototype dropped an image, but the prior copy at migrated/assets/<subpath> remains. By default migrate does not delete stale assets; the bundle keeps them, the next zip includes them, and they are harmless 0-link files.

The --clean flag changes this:

  1. --clean implies --force. Every page in scope is re-rendered so the run's bundledAssets Set is the complete union of assets referenced by every migrated page. Without this implication, an idempotent-skipped page's assets would be absent from the new Set and --clean would delete them — producing deploy-time 404s on the skipped page. Surface the implication in the migrate plan: --clean → --force on N pages.
  2. At the start of the run, capture priorBundle = state.json.migrate.bundledAssets[] (or an empty list if the key is absent).
  3. Run normal bundling on every page (no skips). bundledAssets is the new, complete set.
  4. After all pages are written, compute stale = priorBundle.filter(p => !bundledAssets.has(p)).
  5. For each stale subpath, fs.unlink(<migrated>/assets/<subpath>).
  6. Remove empty parent directories.
  7. Record the cleanup in state.json.migrate.cleanedAssets[].

--clean is opt-in because deleting files is the kind of action the user should authorise; bundling it with --force is necessary because the two flags interact — additive runs can safely use the idempotent skip, but stale-cleanup runs need the full per-page asset scan to compute the orphan set correctly. Without --clean, the run is purely additive and the idempotent skip is honored.

Idempotent skip interaction

The idempotent skip path (page sha-matched → no re-render) does NOT run asset bundling for that page. The bundled assets that the prior run emitted are still on disk; the new run's bundledAssets set is union'd with state.json.migrate.bundledAssets[] at the start of the run so the cross-page dedup logic still works correctly when only some pages are re-rendered.

--clean cannot rely on this union alone because the prior set also contains stale-asset candidates — exactly the assets --clean is trying to find. The fix is documented in § Stale asset cleanup: --clean implies --force, every page re-renders, and the global Set is rebuilt from scratch.

Configurable prefixes from stardust.json

A project that doesn't have a DESIGN.json.extensions.canon object yet can declare prefixes in a top-level stardust.json#migrate.assetPrefixes[]. The two sources are merged (canon.assetPrefixes first, then stardust.json); a duplicate is harmless.

Cross-host asset references

A src="https://cdn.example.com/foo.jpg" is a scheme-bearing URL and is skipped by the prefix-matching algorithm. If the project wants such URLs vendored, that's a separate --vendor-cdn-assets flag — out of scope for this PR (track under § Out of scope).

URL-encoded subpaths

A reference to /assets/photos/family%20portrait.jpg carries URL-encoded spaces. The subpath used for file-system access is the decoded form (photos/family portrait.jpg); the rewrite preserves the encoded form in the HTML so the served page is HTTP-valid.

The detection regex matches percent-encoded characters; the URL decode happens before file-system access only.

Path-traversal safety

A reference matching /assets/../etc/passwd would resolve to a file outside stardust/current/assets/. The bundler refuses subpaths containing .. segments after the prefix is stripped — logs a kind: "asset-path-traversal" decision and skips the copy. The HTML reference is left unchanged (not rewritten) so the issue surfaces visibly on deploy.

Worked example (the Wasatch project)

Input page home.html:

<link rel="icon" href="../current/assets/favicon.svg">
<img src="../current/assets/generated/wasatch-back-16x9.jpg"
     srcset="../current/assets/generated/wasatch-back-16x9.jpg 1x,
             ../current/assets/generated/wasatch-back-16x9@2x.jpg 2x">
<section style="background-image: url('../current/assets/generated/parallax-bg.jpg')"></section>

Detection surfaces 4 unique subpaths (one shared between src and the 1x descriptor of srcset):

favicon.svg
generated/wasatch-back-16x9.jpg
generated/wasatch-back-16x9@2x.jpg
generated/parallax-bg.jpg

Copy lands them under stardust/migrated/assets/<subpath>. Rewrite produces:

<link rel="icon" href="/assets/favicon.svg">
<img src="/assets/generated/wasatch-back-16x9.jpg"
     srcset="/assets/generated/wasatch-back-16x9.jpg 1x,
             /assets/generated/wasatch-back-16x9@2x.jpg 2x">
<section style="background-image: url('/assets/generated/parallax-bg.jpg')"></section>

The page is now self-contained; cd stardust/migrated && zip -r out.zip . produces a deploy-ready archive.

Out of scope (for this contract)

The following are valuable but explicitly NOT covered:

  • Asset optimisation — image resizing, WebP conversion, responsive srcset generation, image minification. Migrate ships bytes as-authored. Optimisation is a downstream concern (harden? optimise?).
  • Self-hosting Google Fonts — the prototype loads fonts via <link rel="stylesheet" href="https://fonts.googleapis.com/..">, which is a scheme-bearing URL and not bundled. A separate PR adds an opt-in --self-host-fonts flag that downloads the woff2 files and rewrites the CSS.
  • Self-hosting jsDelivr / unpkg / CDN-served JS — same shape as fonts. A separate --vendor-cdn-assets flag is the expected entry point.
  • Per-page-depth relative paths — would let the bundle be served from a non-root subpath without rewriting. Out of scope because root-relative covers the default deploy shape; users who need this run a downstream rewrite (recommend sed -i 's|"/assets/|"./assets/|g' migrated/**/*.html, or a prepare-deploy skill).

References

  • skills/stardust/reference/migrate-output-format.md — the self-contained-bundle contract and the state.json.migrate block downstream consumers rely on.
  • skills/migrate/reference/content-preservation.md § Media references — the original content-preservation rules that this phase elaborates.
  • skills/migrate/reference/metadata-and-jsonld.md § Favicon and § OG image — head-metadata asset references composed with the same prefix set.
  • Wasatch reference implementation: https://github.com/paolomoz/wasatch (scripts/migrate.mjs).

skills

README.md

tile.json