Redesign an existing website to make it better. Built on top of impeccable.
71
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
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.
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.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.
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:
stardust/migrated/assets/<subpath> (§ Copy), and rewrites the
string so references point at the bundled copy (§ Rewrite).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.
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.
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.
srcset multi-URL attributessrcset\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.
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.
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.
@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.
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).
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:
| Prefix | When 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:
http://, https://,
data:, mailto:, tel:, javascript:, #).url.startsWith(prefix).url.slice(prefix.length).<project>/stardust/current/assets/<subpath> to the
source file path.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.
For each unique subpath the detection phase surfaced:
Set<string> bundledAssets — skip if
already copied this run.src = <project>/stardust/current/assets/<subpath>
and dst = <project>/stardust/migrated/assets/<subpath>.fs.access(src) — if it throws, log per § Missing assets and
continue.fs.mkdir(path.dirname(dst), { recursive: true }).fs.copyFile(src, dst).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/.
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 outputPath | Depth | Prefix | Asset reference |
|---|---|---|---|
index.html | 0 | ./ | ./assets/hero.jpg |
beers/index.html | 1 | ../ | ../assets/hero.jpg |
about-us/history.html | 1 | ../ | ../assets/hero.jpg |
about-us/team/index.html | 2 | ../../ | ../../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.
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.
When a referenced subpath has no file under
stardust/current/assets/, the bundler:
Missing assets: block)._meta.json#migrationDecisions[] with
kind: "asset-missing", the subpath, and the referencing
attribute/selector./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.
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.
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>.
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:
--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.priorBundle = state.json.migrate.bundledAssets[] (or an empty list if the
key is absent).bundledAssets
is the new, complete set.stale = priorBundle.filter(p => !bundledAssets.has(p)).fs.unlink(<migrated>/assets/<subpath>).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.
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.
stardust.jsonA 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.
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).
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.
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.
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.jpgCopy 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.
The following are valuable but explicitly NOT covered:
srcset generation, image minification. Migrate
ships bytes as-authored. Optimisation is a downstream concern
(harden? optimise?).<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.--vendor-cdn-assets flag is the
expected entry point.sed -i 's|"/assets/|"./assets/|g' migrated/**/*.html, or a
prepare-deploy skill).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.scripts/migrate.mjs)..tessl-plugin
skills
audit
reference
deploy
diff
direct
extract
migrate
prepare-migration
prototype
replica
reskin
rollout
stardust
uplift
reference