diff --git a/apps/builder/app/builder/shared/asset-manager/asset-info.test.ts b/apps/builder/app/builder/shared/asset-manager/asset-info.test.ts index 73f4214a75de..9e12b396a0bd 100644 --- a/apps/builder/app/builder/shared/asset-manager/asset-info.test.ts +++ b/apps/builder/app/builder/shared/asset-manager/asset-info.test.ts @@ -1,6 +1,10 @@ import { describe, test, expect } from "vitest"; -import type { Pages, Props, Styles } from "@webstudio-is/sdk"; -import type { ImageValue, StyleValue } from "@webstudio-is/css-engine"; +import type { Pages, Props, Styles, Asset } from "@webstudio-is/sdk"; +import type { + ImageValue, + FontFamilyValue, + StyleValue, +} from "@webstudio-is/css-engine"; import { __testing__ } from "./asset-info"; const { traverseStyleValue, calculateUsagesByAssetId } = __testing__; @@ -12,7 +16,7 @@ describe("asset-info", () => { type: "image", value: { type: "asset", value: "asset-1" }, }; - const results: ImageValue[] = []; + const results: (ImageValue | FontFamilyValue)[] = []; traverseStyleValue(imageValue, (value) => results.push(value)); expect(results).toEqual([imageValue]); }); @@ -30,7 +34,7 @@ describe("asset-info", () => { type: "tuple", value: [imageValue1, imageValue2], }; - const results: ImageValue[] = []; + const results: (ImageValue | FontFamilyValue)[] = []; traverseStyleValue(tupleValue, (value) => results.push(value)); expect(results).toEqual([imageValue1, imageValue2]); }); @@ -48,7 +52,7 @@ describe("asset-info", () => { type: "layers", value: [imageValue1, imageValue2], }; - const results: ImageValue[] = []; + const results: (ImageValue | FontFamilyValue)[] = []; traverseStyleValue(layersValue, (value) => results.push(value)); expect(results).toEqual([imageValue1, imageValue2]); }); @@ -58,7 +62,7 @@ describe("asset-info", () => { type: "keyword", value: "auto", }; - const results: ImageValue[] = []; + const results: (ImageValue | FontFamilyValue)[] = []; traverseStyleValue(keywordValue, (value) => results.push(value)); expect(results).toEqual([]); }); @@ -76,10 +80,20 @@ describe("asset-info", () => { type: "layers", value: [nestedTuple], }; - const results: ImageValue[] = []; + const results: (ImageValue | FontFamilyValue)[] = []; traverseStyleValue(layersValue, (value) => results.push(value)); expect(results).toEqual([imageValue]); }); + + test("calls callback for fontFamily values", () => { + const fontFamilyValue: FontFamilyValue = { + type: "fontFamily", + value: ["CustomFont", "Arial", "sans-serif"], + }; + const results: (ImageValue | FontFamilyValue)[] = []; + traverseStyleValue(fontFamilyValue, (value) => results.push(value)); + expect(results).toEqual([fontFamilyValue]); + }); }); describe("calculateUsagesByAssetId", () => { @@ -99,8 +113,9 @@ describe("asset-info", () => { }; const props: Props = new Map(); const styles: Styles = new Map(); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.get("favicon-asset")).toEqual([{ type: "favicon" }]); }); @@ -121,8 +136,9 @@ describe("asset-info", () => { }; const props: Props = new Map(); const styles: Styles = new Map(); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.get("social-asset")).toEqual([ { type: "socialImage", pageId: "home" }, @@ -155,8 +171,9 @@ describe("asset-info", () => { }; const props: Props = new Map(); const styles: Styles = new Map(); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.get("thumbnail-asset")).toEqual([ { type: "marketplaceThumbnail", pageId: "page-1" }, @@ -190,8 +207,9 @@ describe("asset-info", () => { ], ]); const styles: Styles = new Map(); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.get("asset-1")).toEqual([ { type: "prop", propId: "prop-1" }, @@ -235,8 +253,9 @@ describe("asset-info", () => { ], ]); const styles: Styles = new Map(); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.get("asset-1")).toBeUndefined(); }); @@ -270,8 +289,9 @@ describe("asset-info", () => { }, ], ]); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.get("asset-1")).toEqual([ { type: "style", styleDeclKey: "style-1:breakpoint-1:property" }, @@ -294,8 +314,9 @@ describe("asset-info", () => { }; const props: Props = new Map(); const styles: Styles = new Map(); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.size).toBe(0); }); @@ -340,8 +361,9 @@ describe("asset-info", () => { }, ], ]); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.get("asset-1")).toHaveLength(4); expect(usages.get("asset-1")).toEqual([ @@ -356,8 +378,9 @@ describe("asset-info", () => { const pages = undefined; const props: Props = new Map(); const styles: Styles = new Map(); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.size).toBe(0); }); @@ -400,8 +423,9 @@ describe("asset-info", () => { }, ], ]); + const assets = new Map(); - const usages = calculateUsagesByAssetId({ pages, props, styles }); + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); expect(usages.get("asset-1")).toEqual([ { type: "style", styleDeclKey: "style-1:breakpoint-1:property" }, @@ -410,5 +434,226 @@ describe("asset-info", () => { { type: "style", styleDeclKey: "style-1:breakpoint-1:property" }, ]); }); + + test("tracks font asset usage in fontFamily styles", () => { + const pages: Pages = { + meta: {}, + homePage: { + id: "home", + name: "Home", + path: "", + title: "Home", + meta: {}, + rootInstanceId: "root", + }, + pages: [], + folders: [], + }; + const props: Props = new Map(); + const styles: Styles = new Map([ + [ + "style-1:breakpoint-1:fontFamily", + { + breakpointId: "breakpoint-1", + styleSourceId: "style-1", + property: "fontFamily", + value: { + type: "fontFamily", + value: ["CustomFont", "Arial", "sans-serif"], + }, + }, + ], + ]); + const assets = new Map([ + [ + "font-asset-1", + { + id: "font-asset-1", + type: "font", + name: "CustomFont", + format: "woff2", + size: 5000, + meta: { family: "CustomFont", style: "normal", weight: 400 }, + createdAt: "2024-01-01", + projectId: "project-id", + }, + ], + ]); + + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); + + expect(usages.get("font-asset-1")).toEqual([ + { type: "style", styleDeclKey: "style-1:breakpoint-1:fontFamily" }, + ]); + }); + + test("tracks multiple font assets in same fontFamily style", () => { + const pages: Pages = { + meta: {}, + homePage: { + id: "home", + name: "Home", + path: "", + title: "Home", + meta: {}, + rootInstanceId: "root", + }, + pages: [], + folders: [], + }; + const props: Props = new Map(); + const styles: Styles = new Map([ + [ + "style-1:breakpoint-1:fontFamily", + { + breakpointId: "breakpoint-1", + styleSourceId: "style-1", + property: "fontFamily", + value: { + type: "fontFamily", + value: ["CustomFont", "AnotherFont"], + }, + }, + ], + ]); + const assets = new Map([ + [ + "font-asset-1", + { + id: "font-asset-1", + type: "font", + name: "CustomFont", + format: "woff2", + size: 5000, + meta: { family: "CustomFont", style: "normal", weight: 400 }, + createdAt: "2024-01-01", + projectId: "project-id", + }, + ], + [ + "font-asset-2", + { + id: "font-asset-2", + type: "font", + name: "AnotherFont", + format: "woff2", + size: 6000, + meta: { family: "AnotherFont", style: "normal", weight: 400 }, + createdAt: "2024-01-01", + projectId: "project-id", + }, + ], + ]); + + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); + + expect(usages.get("font-asset-1")).toEqual([ + { type: "style", styleDeclKey: "style-1:breakpoint-1:fontFamily" }, + ]); + expect(usages.get("font-asset-2")).toEqual([ + { type: "style", styleDeclKey: "style-1:breakpoint-1:fontFamily" }, + ]); + }); + + test("ignores font families without matching assets", () => { + const pages: Pages = { + meta: {}, + homePage: { + id: "home", + name: "Home", + path: "", + title: "Home", + meta: {}, + rootInstanceId: "root", + }, + pages: [], + folders: [], + }; + const props: Props = new Map(); + const styles: Styles = new Map([ + [ + "style-1:breakpoint-1:fontFamily", + { + breakpointId: "breakpoint-1", + styleSourceId: "style-1", + property: "fontFamily", + value: { + type: "fontFamily", + value: ["Arial", "sans-serif"], + }, + }, + ], + ]); + const assets = new Map(); + + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); + + expect(usages.size).toBe(0); + }); + + test("tracks same font used in multiple styles", () => { + const pages: Pages = { + meta: {}, + homePage: { + id: "home", + name: "Home", + path: "", + title: "Home", + meta: {}, + rootInstanceId: "root", + }, + pages: [], + folders: [], + }; + const props: Props = new Map(); + const styles: Styles = new Map([ + [ + "style-1:breakpoint-1:fontFamily", + { + breakpointId: "breakpoint-1", + styleSourceId: "style-1", + property: "fontFamily", + value: { + type: "fontFamily", + value: ["CustomFont"], + }, + }, + ], + [ + "style-2:breakpoint-1:fontFamily", + { + breakpointId: "breakpoint-1", + styleSourceId: "style-2", + property: "fontFamily", + value: { + type: "fontFamily", + value: ["CustomFont"], + }, + }, + ], + ]); + const assets = new Map([ + [ + "font-asset-1", + { + id: "font-asset-1", + type: "font", + name: "CustomFont", + format: "woff2", + size: 5000, + meta: { family: "CustomFont", style: "normal", weight: 400 }, + createdAt: "2024-01-01", + projectId: "project-id", + }, + ], + ]); + + const usages = calculateUsagesByAssetId({ pages, props, styles, assets }); + + expect(usages.get("font-asset-1")).toEqual([ + { type: "style", styleDeclKey: "style-1:breakpoint-1:fontFamily" }, + { type: "style", styleDeclKey: "style-2:breakpoint-1:fontFamily" }, + ]); + }); }); }); diff --git a/apps/builder/app/builder/shared/asset-manager/asset-info.tsx b/apps/builder/app/builder/shared/asset-manager/asset-info.tsx index 7addf26b27c9..5a06010dfd0b 100644 --- a/apps/builder/app/builder/shared/asset-manager/asset-info.tsx +++ b/apps/builder/app/builder/shared/asset-manager/asset-info.tsx @@ -6,7 +6,11 @@ import { computed } from "nanostores"; import { useStore } from "@nanostores/react"; import { getMimeTypeByExtension } from "@webstudio-is/sdk"; import type { Asset, Pages, Props, Styles, Instance } from "@webstudio-is/sdk"; -import type { ImageValue, StyleValue } from "@webstudio-is/css-engine"; +import type { + ImageValue, + FontFamilyValue, + StyleValue, +} from "@webstudio-is/css-engine"; import { mapGetOrInsert } from "~/shared/shim"; import { Box, @@ -85,11 +89,14 @@ type AssetUsage = const traverseStyleValue = ( styleValue: StyleValue, - callback: (value: ImageValue) => void + callback: (value: ImageValue | FontFamilyValue) => void ) => { if (styleValue.type === "image") { callback(styleValue); } + if (styleValue.type === "fontFamily") { + callback(styleValue); + } if (styleValue.type === "tuple") { for (const item of styleValue.value) { traverseStyleValue(item, callback); @@ -106,12 +113,23 @@ const calculateUsagesByAssetId = ({ pages, props, styles, + assets, }: { pages: Pages | undefined; props: Props; styles: Styles; + assets: Map; }): Map => { const usagesByAsset = new Map(); + + // Build font family to asset ID map once for O(1) lookups + const fontFamilyToAssetId = new Map(); + for (const asset of assets.values()) { + if (asset.type === "font") { + fontFamilyToAssetId.set(asset.meta.family, asset.id); + } + } + if (pages?.meta?.faviconAssetId) { const usages = mapGetOrInsert(usagesByAsset, pages.meta.faviconAssetId, []); usages.push({ type: "favicon" }); @@ -148,24 +166,30 @@ const calculateUsagesByAssetId = ({ } } for (const [styleDeclKey, styleDecl] of styles) { - traverseStyleValue(styleDecl.value, (imageValue) => { - if (imageValue.value.type === "asset") { - const usages = mapGetOrInsert( - usagesByAsset, - imageValue.value.value, - [] - ); + traverseStyleValue(styleDecl.value, (value) => { + if (value.type === "image" && value.value.type === "asset") { + const usages = mapGetOrInsert(usagesByAsset, value.value.value, []); usages.push({ type: "style", styleDeclKey }); } + if (value.type === "fontFamily") { + // Match each font family name to its asset ID + for (const fontFamily of value.value) { + const assetId = fontFamilyToAssetId.get(fontFamily); + if (assetId !== undefined) { + const usages = mapGetOrInsert(usagesByAsset, assetId, []); + usages.push({ type: "style", styleDeclKey }); + } + } + } }); } return usagesByAsset; }; const $usagesByAssetId = computed( - [$pages, $props, $styles], - (pages, props, styles) => { - return calculateUsagesByAssetId({ pages, props, styles }); + [$pages, $props, $styles, $assets], + (pages, props, styles, assets) => { + return calculateUsagesByAssetId({ pages, props, styles, assets }); } );