diff --git a/docs/concepts/compositions.mdx b/docs/concepts/compositions.mdx
index 768e3bd534..fd6c754c82 100644
--- a/docs/concepts/compositions.mdx
+++ b/docs/concepts/compositions.mdx
@@ -242,6 +242,35 @@ script, CLI overrides, and which value wins. If you are building tooling on
`@hyperframes/core`, `extractCompositionMetadata()` reads the same
`data-composition-variables` array — that is how Studio builds its editing UI.
+## Load a composition's own files from its script
+
+When a composition is mounted, a `src` or `href` attribute that points at a
+file next to it is rewritten to where that file lives, so
+`
` keeps working. A path your script builds is a
+plain string and is never rewritten, so resolve it with
+`window.__hyperframes.assetUrl()`. It takes a path written relative to the
+composition's own file and returns a URL the page can load, whether the file is
+mounted, previewed or opened on its own.
+
+A path meant from the project root, such as `assets/logo.png` in a composition
+at `compositions/intro.html` whose image sits in the project's own `assets/`
+folder, already resolves from the page. Leave it as a plain string:
+`assetUrl()` would look for it beside the composition and miss.
+
+```html compositions/globe/globe.html
+
+
+
+```
+
+A separate script file (`
+`,
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ expect(bundled).toContain('var __hfCompositionSrc = "compositions/blk/blk.html";');
+ });
+
+ it("binds a composition authored in a mounted file to that file", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+`,
+ "compositions/blk/blk.html": ``,
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ expect(bundled).toMatch(
+ /var __hfCompId = "chip";(?:(?!__hfCompId)[\s\S])*var __hfCompositionSrc = "compositions\/blk\/blk\.html";/,
+ );
+ });
+
it("inlines an in-project sub-composition script but not one reached through a symlink escaping the project root", async () => {
// Security: a shared/cloned project may carry a symlink pointing outside the
// root (e.g. ext -> /etc). The bundler reads+inlines local assets, so it must
diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts
index c0fcadb64d..c4ca5a1714 100644
--- a/packages/core/src/compiler/inlineSubCompositions.ts
+++ b/packages/core/src/compiler/inlineSubCompositions.ts
@@ -358,6 +358,7 @@ export function inlineSubCompositions(
runtimeScope || undefined,
runtimeCompId || scopeCompId || scriptCompositionId,
authoredRootId,
+ src,
)
: wrapInlineScriptWithErrorBoundary(scriptEl.textContent || "", scriptErrorLabel);
scripts.push(wrappedScript);
diff --git a/packages/core/src/runtime/assetUrl.ts b/packages/core/src/runtime/assetUrl.ts
new file mode 100644
index 0000000000..6e94e5f044
--- /dev/null
+++ b/packages/core/src/runtime/assetUrl.ts
@@ -0,0 +1,8 @@
+/**
+ * Resolves a path the calling composition wrote relative to its own file. At the top level, and
+ * for a composition opened on its own, that file is the page. Inside a mounted sub-composition
+ * the scoped wrapper (compiler/compositionScoping.ts) replaces this with one bound to that file.
+ */
+export function assetUrl(path: string): string {
+ return new URL(path, document.baseURI).href;
+}
diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts
index a479bcf7c6..4c95ebe19f 100644
--- a/packages/core/src/runtime/compositionLoader.test.ts
+++ b/packages/core/src/runtime/compositionLoader.test.ts
@@ -36,6 +36,24 @@ describe("loadExternalCompositions", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
+ it("binds a fetched composition's scripts to its own URL for __hyperframes.assetUrl", async () => {
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-src", "https://example.com/blocks/blk/blk.html");
+ host.setAttribute("data-composition-id", "blk");
+ document.body.appendChild(host);
+ const compositionHtml =
+ `