diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 93945b411..1e2ddf89d 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -725,4 +725,177 @@ describe("bundleToSingleHtml", () => { expect(bundled).toContain('url("fonts/brand.woff2")'); expect(bundled).not.toContain('url("../fonts/brand.woff2")'); }); + + it("resolves CSS @import statements when inlining stylesheets", async () => { + const dir = makeTempProject({ + "index.html": ` +
+ + + +`, + "styles/canvas.css": `@import url('./tokens.css');\nbody { margin: 0; }`, + "styles/tokens.css": `:root { --brand: #ff5728; }`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("--brand: #ff5728"); + expect(bundled).not.toContain("@import"); + expect(bundled).toContain("margin: 0"); + }); + + it("resolves nested CSS @import chains", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "styles/main.css": `@import url('./base.css');\n.main { color: red; }`, + "styles/base.css": `@import url('../tokens.css');\n.base { display: flex; }`, + "tokens.css": `:root { --tk-teal: #1a3540; }`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("--tk-teal: #1a3540"); + expect(bundled).toContain("display: flex"); + expect(bundled).toContain("color: red"); + expect(bundled).not.toContain("@import"); + }); + + it("wraps @import with media query in @media block", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "print.css": `@import url('./print-tokens.css') print;\nbody { font-size: 12pt; }`, + "print-tokens.css": `.print-only { display: block; }`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("@media print"); + expect(bundled).toContain("display: block"); + expect(bundled).not.toContain("@import"); + }); + + it("preserves @import for absolute URLs", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "app.css": `@import url('https://fonts.googleapis.com/css2?family=Inter');\nbody { margin: 0; }`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("@import url('https://fonts.googleapis.com/css2?family=Inter')"); + expect(bundled).toContain("margin: 0"); + }); + + it("rebases url() paths in @import-resolved CSS to project root", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "styles/canvas.css": `@import url('./tokens.css');\nbody { margin: 0; }`, + "styles/tokens.css": `@font-face { src: url('assets/fonts/brand.woff2') format('woff2'); }`, + "styles/assets/fonts/brand.woff2": "fake-font-data", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('styles/assets/fonts/brand.woff2')"); + expect(bundled).not.toContain("url('assets/fonts/brand.woff2')"); + expect(bundled).not.toContain("@import"); + }); + + it("rebases url() paths in -inlined CSS from subdirectories", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "theme/styles.css": `.bg { background: url('./images/grain.png'); }`, + "theme/images/grain.png": "fake-image-data", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('theme/images/grain.png')"); + expect(bundled).not.toContain("url('./images/grain.png')"); + }); + + it("rebases url() paths with ../ traversal in nested @import", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "styles/main.css": `@import url('./base/reset.css');`, + "styles/base/reset.css": `body { background: url('../../assets/bg.png'); }`, + "assets/bg.png": "fake-image", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('assets/bg.png')"); + expect(bundled).not.toContain("url('../../assets/bg.png')"); + }); + + it("preserves absolute and data url() references during rebasing", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "styles/app.css": [ + `@font-face { src: url('https://cdn.example.com/font.woff2'); }`, + `.icon { background: url('data:image/svg+xml,'); }`, + `.local { background: url('./img/bg.png'); }`, + ].join("\n"), + "styles/img/bg.png": "fake", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('https://cdn.example.com/font.woff2')"); + expect(bundled).toContain("url('data:image/svg+xml,')"); + expect(bundled).toContain("url('styles/img/bg.png')"); + }); + + it("preserves url() query strings and hash fragments during rebasing", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "styles/icons.css": `.icon { background: url('./sprite.png?v=2#section'); }`, + "styles/sprite.png": "fake-sprite", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('styles/sprite.png?v=2#section')"); + }); }); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 3e1377d84..1bbbe89a5 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -1,5 +1,5 @@ import { readFileSync, existsSync } from "fs"; -import { join, resolve, isAbsolute, sep } from "path"; +import { join, resolve, relative, dirname, isAbsolute, sep } from "path"; import { transformSync } from "esbuild"; import { compileHtml, type MediaDurationProber } from "./htmlCompiler"; import { @@ -90,6 +90,59 @@ function safeReadFile(filePath: string): string | null { } } +const CSS_IMPORT_RE = + /@import\s+(?:url\(\s*(["']?)([^)"']+)\1\s*\)|(["'])([^"']+)\3)\s*([^;]*);\s*/g; + +const REBASE_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g; + +function rebaseCssUrls(css: string, cssFileDir: string, projectDir: string): string { + const resolvedRoot = resolve(projectDir); + const resolvedDir = resolve(cssFileDir); + if (resolvedDir === resolvedRoot) return css; + return css.replace(REBASE_URL_RE, (full, quote: string, urlValue: string) => { + if (!urlValue || !isRelativeUrl(urlValue)) return full; + const { basePath, suffix } = splitUrlSuffix(urlValue.trim()); + if (!basePath) return full; + const absolutePath = resolve(resolvedDir, basePath); + const rebased = relative(resolvedRoot, absolutePath); + if (rebased === basePath) return full; + return `url(${quote || ""}${rebased}${suffix}${quote || ""})`; + }); +} + +function inlineCssFile( + css: string, + cssFileDir: string, + projectDir: string, + visited: Set0&&H.setDuration(y),Ve()}}if(_t%20===0&&Ve(),_t%30===0&&be(),t.capturedTimeline){let m=G(t.capturedTimeline,0);m>0&&H.setDuration(m)}if(H.isPlaying()&&!t.mediaOutputMuted)if(Ae.isActive()&&Ae.context){let m=Ae.getTime();m>=0&&H.attachAudioSource({currentTimeSeconds:m})}else{let m=document.querySelectorAll("audio[data-start]"),h=!1;for(let y of m){if(!(y instanceof HTMLMediaElement)||!y.isConnected)continue;let A=Number.parseFloat(y.dataset.start??""),v=Number.parseFloat(y.dataset.duration??""),j=Number.isFinite(v)&&v>0?A+v:1/0,ne=Number.parseFloat(y.dataset.playbackStart??y.dataset.mediaStart??"0")||0;if(Number.isFinite(A)&&t.currentTime>=A&&t.currentTime =0&&n[a]==="ET";a--)n[a]="EN";for(a=l+1;a"|')(?
))\k
\s*\]/g;
- parseARIASelector = (selector) => {
- if (selector.length > 1e4) {
- throw new Error(`Selector ${selector} is too long`);
- }
- const queryOptions = {};
- const defaultName = selector.replace(ATTRIBUTE_REGEXP, (_2, attribute2, __, value) => {
- assert(isKnownAttribute(attribute2), `Unknown aria attribute "${attribute2}" in selector`);
- queryOptions[attribute2] = value;
- return "";
- });
- if (defaultName && !queryOptions.name) {
- queryOptions.name = defaultName;
- }
- return queryOptions;
- };
- ARIAQueryHandler = class extends QueryHandler {
- static querySelector = async (node, selector, { ariaQuerySelector }) => {
- return await ariaQuerySelector(node, selector);
- };
- static async *queryAll(element, selector) {
- const { name, role } = parseARIASelector(selector);
- yield* element.queryAXTree(name, role);
- }
- static queryOne = async (element, selector) => {
- return await AsyncIterableUtil.first(this.queryAll(element, selector)) ?? null;
- };
- };
- }
-});
-
-// ../../node_modules/.bun/puppeteer-core@24.43.1/node_modules/puppeteer-core/lib/esm/puppeteer/common/CSSQueryHandler.js
-var CSSQueryHandler;
-var init_CSSQueryHandler = __esm({
- "../../node_modules/.bun/puppeteer-core@24.43.1/node_modules/puppeteer-core/lib/esm/puppeteer/common/CSSQueryHandler.js"() {
- init_QueryHandler();
- CSSQueryHandler = class extends QueryHandler {
- static querySelector = (element, selector, { cssQuerySelector }) => {
- return cssQuerySelector(element, selector);
- };
- static querySelectorAll = (element, selector, { cssQuerySelectorAll }) => {
- return cssQuerySelectorAll(element, selector);
- };
- };
- }
-});
-
-// ../../node_modules/.bun/puppeteer-core@24.43.1/node_modules/puppeteer-core/lib/esm/puppeteer/generated/injected.js
-var source;
-var init_injected = __esm({
- "../../node_modules/.bun/puppeteer-core@24.43.1/node_modules/puppeteer-core/lib/esm/puppeteer/generated/injected.js"() {
- source = '"use strict";var g=Object.defineProperty;var X=Object.getOwnPropertyDescriptor;var B=Object.getOwnPropertyNames;var Y=Object.prototype.hasOwnProperty;var l=(t,e)=>{for(var r in e)g(t,r,{get:e[r],enumerable:!0})},G=(t,e,r,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of B(e))!Y.call(t,s)&&s!==r&&g(t,s,{get:()=>e[s],enumerable:!(o=X(e,s))||o.enumerable});return t};var J=t=>G(g({},"__esModule",{value:!0}),t);var pe={};l(pe,{default:()=>he});module.exports=J(pe);var N=class extends Error{constructor(e,r){super(e,r),this.name=this.constructor.name}get[Symbol.toStringTag](){return this.constructor.name}},p=class extends N{};var c=class t{static create(e){return new t(e)}static async race(e){let r=new Set;try{let o=e.map(s=>s instanceof t?(s.#s&&r.add(s),s.valueOrThrow()):s);return await Promise.race(o)}finally{for(let o of r)o.reject(new Error("Timeout cleared"))}}#e=!1;#r=!1;#o;#t;#a=new Promise(e=>{this.#t=e});#s;#i;constructor(e){e&&e.timeout>0&&(this.#i=new p(e.message),this.#s=setTimeout(()=>{this.reject(this.#i)},e.timeout))}#l(e){clearTimeout(this.#s),this.#o=e,this.#t()}resolve(e){this.#r||this.#e||(this.#e=!0,this.#l(e))}reject(e){this.#r||this.#e||(this.#r=!0,this.#l(e))}resolved(){return this.#e}finished(){return this.#e||this.#r}value(){return this.#o}#n;valueOrThrow(){return this.#n||(this.#n=(async()=>{if(await this.#a,this.#r)throw this.#o;return this.#o})()),this.#n}};var L=new Map,W=t=>{let e=L.get(t);return e||(e=new Function(`return ${t}`)(),L.set(t,e),e)};var b={};l(b,{ariaQuerySelector:()=>z,ariaQuerySelectorAll:()=>x});var z=(t,e)=>globalThis.__ariaQuerySelector(t,e),x=async function*(t,e){yield*await globalThis.__ariaQuerySelectorAll(t,e)};var E={};l(E,{cssQuerySelector:()=>K,cssQuerySelectorAll:()=>Z});var K=(t,e)=>t.querySelector(e),Z=function(t,e){return t.querySelectorAll(e)};var A={};l(A,{customQuerySelectors:()=>P});var v=class{#e=new Map;register(e,r){if(!r.queryOne&&r.queryAll){let o=r.queryAll;r.queryOne=(s,i)=>{for(let n of o(s,i))return n;return null}}else if(r.queryOne&&!r.queryAll){let o=r.queryOne;r.queryAll=(s,i)=>{let n=o(s,i);return n?[n]:[]}}else if(!r.queryOne||!r.queryAll)throw new Error("At least one query method must be defined.");this.#e.set(e,{querySelector:r.queryOne,querySelectorAll:r.queryAll})}unregister(e){this.#e.delete(e)}get(e){return this.#e.get(e)}clear(){this.#e.clear()}},P=new v;var R={};l(R,{pierceQuerySelector:()=>ee,pierceQuerySelectorAll:()=>te});var ee=(t,e)=>{let r=null,o=s=>{let i=document.createTreeWalker(s,NodeFilter.SHOW_ELEMENT);do{let n=i.currentNode;n.shadowRoot&&o(n.shadowRoot),!(n instanceof ShadowRoot)&&n!==s&&!r&&n.matches(e)&&(r=n)}while(!r&&i.nextNode())};return t instanceof Document&&(t=t.documentElement),o(t),r},te=(t,e)=>{let r=[],o=s=>{let i=document.createTreeWalker(s,NodeFilter.SHOW_ELEMENT);do{let n=i.currentNode;n.shadowRoot&&o(n.shadowRoot),!(n instanceof ShadowRoot)&&n!==s&&n.matches(e)&&r.push(n)}while(i.nextNode())};return t instanceof Document&&(t=t.documentElement),o(t),r};var u=(t,e)=>{if(!t)throw new Error(e)};var y=class{#e;#r;#o;#t;constructor(e,r){this.#e=e,this.#r=r}async start(){let e=this.#t=c.create(),r=await this.#e();if(r){e.resolve(r);return}this.#o=new MutationObserver(async()=>{let o=await this.#e();o&&(e.resolve(o),await this.stop())}),this.#o.observe(this.#r,{childList:!0,subtree:!0,attributes:!0})}async stop(){u(this.#t,"Polling never started."),this.#t.finished()||this.#t.reject(new Error("Polling stopped")),this.#o&&(this.#o.disconnect(),this.#o=void 0)}result(){return u(this.#t,"Polling never started."),this.#t.valueOrThrow()}},w=class{#e;#r;constructor(e){this.#e=e}async start(){let e=this.#r=c.create(),r=await this.#e();if(r){e.resolve(r);return}let o=async()=>{if(e.finished())return;let s=await this.#e();if(!s){window.requestAnimationFrame(o);return}e.resolve(s),await this.stop()};window.requestAnimationFrame(o)}async stop(){u(this.#r,"Polling never started."),this.#r.finished()||this.#r.reject(new Error("Polling stopped"))}result(){return u(this.#r,"Polling never started."),this.#r.valueOrThrow()}},T=class{#e;#r;#o;#t;constructor(e,r){this.#e=e,this.#r=r}async start(){let e=this.#t=c.create(),r=await this.#e();if(r){e.resolve(r);return}this.#o=setInterval(async()=>{let o=await this.#e();o&&(e.resolve(o),await this.stop())},this.#r)}async stop(){u(this.#t,"Polling never started."),this.#t.finished()||this.#t.reject(new Error("Polling stopped")),this.#o&&(clearInterval(this.#o),this.#o=void 0)}result(){return u(this.#t,"Polling never started."),this.#t.valueOrThrow()}};var _={};l(_,{PCombinator:()=>H,pQuerySelector:()=>fe,pQuerySelectorAll:()=>$});var a=class{static async*map(e,r){for await(let o of e)yield await r(o)}static async*flatMap(e,r){for await(let o of e)yield*r(o)}static async collect(e){let r=[];for await(let o of e)r.push(o);return r}static async first(e){for await(let r of e)return r}};var C={};l(C,{textQuerySelectorAll:()=>m});var re=new Set(["checkbox","image","radio"]),oe=t=>t instanceof HTMLSelectElement||t instanceof HTMLTextAreaElement||t instanceof HTMLInputElement&&!re.has(t.type),se=new Set(["SCRIPT","STYLE"]),f=t=>!se.has(t.nodeName)&&!document.head?.contains(t),I=new WeakMap,F=t=>{for(;t;)I.delete(t),t instanceof ShadowRoot?t=t.host:t=t.parentNode},j=new WeakSet,ne=new MutationObserver(t=>{for(let e of t)F(e.target)}),d=t=>{let e=I.get(t);if(e||(e={full:"",immediate:[]},!f(t)))return e;let r="";if(oe(t))e.full=t.value,e.immediate.push(t.value),t.addEventListener("input",o=>{F(o.target)},{once:!0,capture:!0});else{for(let o=t.firstChild;o;o=o.nextSibling){if(o.nodeType===Node.TEXT_NODE){e.full+=o.nodeValue??"",r+=o.nodeValue??"";continue}r&&e.immediate.push(r),r="",o.nodeType===Node.ELEMENT_NODE&&(e.full+=d(o).full)}r&&e.immediate.push(r),t instanceof Element&&t.shadowRoot&&(e.full+=d(t.shadowRoot).full),j.has(t)||(ne.observe(t,{childList:!0,characterData:!0,subtree:!0}),j.add(t))}return I.set(t,e),e};var m=function*(t,e){let r=!1;for(let o of t.childNodes)if(o instanceof Element&&f(o)){let s;o.shadowRoot?s=m(o.shadowRoot,e):s=m(o,e);for(let i of s)yield i,r=!0}r||t instanceof Element&&f(t)&&d(t).full.includes(e)&&(yield t)};var k={};l(k,{checkVisibility:()=>le,pierce:()=>S,pierceAll:()=>O});var ie=["hidden","collapse"],le=(t,e)=>{if(!t)return e===!1;if(e===void 0)return t;let r=t.nodeType===Node.TEXT_NODE?t.parentElement:t,o=window.getComputedStyle(r),s=o&&!ie.includes(o.visibility)&&!ae(r);return e===s?t:!1};function ae(t){let e=t.getBoundingClientRect();return e.width===0||e.height===0}var ce=t=>"shadowRoot"in t&&t.shadowRoot instanceof ShadowRoot;function*S(t){ce(t)?yield t.shadowRoot:yield t}function*O(t){t=S(t).next().value,yield t;let e=[document.createTreeWalker(t,NodeFilter.SHOW_ELEMENT)];for(let r of e){let o;for(;o=r.nextNode();)o.shadowRoot&&(yield o.shadowRoot,e.push(document.createTreeWalker(o.shadowRoot,NodeFilter.SHOW_ELEMENT)))}}var D={};l(D,{xpathQuerySelectorAll:()=>q});var q=function*(t,e,r=-1){let s=(t.ownerDocument||document).evaluate(e,t,null,XPathResult.ORDERED_NODE_ITERATOR_TYPE),i=[],n;for(;(n=s.iterateNext())&&(i.push(n),!(r&&i.length===r)););for(let h=0;h
[o,r.calculate(o)]).sort(([,o],[,s])=>U(o,s)).map(([o])=>o)},$=function(t,e){let r=JSON.parse(e);if(r.some(o=>{let s=0;return o.some(i=>(typeof i=="string"?++s:s=0,s>1))}))throw new Error("Multiple deep combinators found in sequence.");return de(a.flatMap(r,o=>{let s=new Q(t,o);return s.run(),s.elements}))},fe=async function(t,e){for await(let r of $(t,e))return r;return null};var me=Object.freeze({...b,...A,...R,..._,...C,...k,...D,...E,Deferred:c,createFunction:W,createTextContent:d,IntervalPoller:T,isSuitableNodeForTextMatching:f,MutationPoller:y,RAFPoller:w}),he=me;\n';
- }
-});
-
-// ../../node_modules/.bun/puppeteer-core@24.43.1/node_modules/puppeteer-core/lib/esm/puppeteer/common/ScriptInjector.js
-var ScriptInjector, scriptInjector;
-var init_ScriptInjector = __esm({
- "../../node_modules/.bun/puppeteer-core@24.43.1/node_modules/puppeteer-core/lib/esm/puppeteer/common/ScriptInjector.js"() {
- init_injected();
- ScriptInjector = class {
- #updated = false;
- #amendments = /* @__PURE__ */ new Set();
- // Appends a statement of the form `(PuppeteerUtil) => {...}`.
- append(statement) {
- this.#update(() => {
- this.#amendments.add(statement);
- });
- }
- pop(statement) {
- this.#update(() => {
- this.#amendments.delete(statement);
- });
- }
- inject(inject, force = false) {
- if (this.#updated || force) {
- inject(this.#get());
- }
- this.#updated = false;
- }
- #update(callback) {
- callback();
- this.#updated = true;
- }
- #get() {
- return `(() => {
- const module = {};
- ${source}
- ${[...this.#amendments].map((statement) => {
- return `(${statement})(module.exports.default);`;
- }).join("")}
- return module.exports.default;
- })()`;
- }
- };
- scriptInjector = new ScriptInjector();
- }
-});
-
-// ../../node_modules/.bun/puppeteer-core@24.43.1/node_modules/puppeteer-core/lib/esm/puppeteer/common/CustomQueryHandler.js
-var CustomQueryHandlerRegistry, customQueryHandlers;
-var init_CustomQueryHandler = __esm({
- "../../node_modules/.bun/puppeteer-core@24.43.1/node_modules/puppeteer-core/lib/esm/puppeteer/common/CustomQueryHandler.js"() {
- init_assert();
- init_Function();
- init_QueryHandler();
- init_ScriptInjector();
- CustomQueryHandlerRegistry = class {
- #handlers = /* @__PURE__ */ new Map();
- get(name) {
- const handler4 = this.#handlers.get(name);
- return handler4 ? handler4[1] : void 0;
- }
- /**
- * Registers a {@link CustomQueryHandler | custom query handler}.
- *
- * @remarks
- * After registration, the handler can be used everywhere where a selector is
- * expected by prepending the selection string with `