Setting the file. One moment. Font Contract · Wix Headless Replatform · wix/skills · Skills Docs28.10
Workflow
— line 258
This file
- Number
- 28.45
- Position
- 45 of 89
- Type
- JavaScript
- Size
- 10 KB
- Lines
- 326
scripts/lib/font-contract.mjs
JavaScript·326 lines·10 KB
,
".tsx"
]);
7
8export function buildFontManifest({ html, baseUrl, stylesheets = [] }) {
9 const inlineBlocks = extractInlineStyleBlocks(html);
10 const inlineFaces = inlineBlocks.flatMap((cssText, index) =>
11 extractFontFaceDeclarations(cssText, baseUrl, {
12 sourceType: "inline-style",
13 sourceIndex: index + 1,
14 sourceUrl: baseUrl,
15 }),
16 );
17 const stylesheetFaces = stylesheets.flatMap((stylesheet, index) =>
18 extractFontFaceDeclarations(stylesheet.cssText, stylesheet.url || baseUrl, {
19 sourceType: stylesheet.sourceType || "stylesheet",
20 sourceIndex: index + 1,
21 sourceUrl: stylesheet.url || baseUrl,
22 }),
23 );
24 const faces = dedupeBy([...inlineFaces, ...stylesheetFaces], fontFaceKey);
25 const contentScripts = detectContentScripts(stripTags(html));
26 return {
27 sourceUrl: baseUrl,
28 inlineStyleBlockCount: inlineBlocks.length,
29 stylesheetCount: stylesheets.length,
30 contentScripts,
31 families: Array.from(new Set(faces.map((face) => face.family))).sort(),
32 faces,
33 };
34}
35
36export function extractInlineStyleBlocks(html) {
37 const blocks = [];
38 const pattern = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
39 let match;
40 while ((match = pattern.exec(html))) blocks.push(match[1]);
41 return blocks;
42}
43
44export function extractFontFaceDeclarations(cssText, baseUrl, { sourceType = "stylesheet", sourceIndex = 1, sourceUrl = baseUrl } = {}) {
45 const faces = [];
46 const pattern = /@font-face\s*{([\s\S]*?)}/gi;
47 let match;
48 while ((match = pattern.exec(cssText))) {
49 const declarations = parseCssDeclarations(match[1]);
50 const family = normalizeFontFamily(declarations["font-family"]);
51 if (!family) continue;
52 const style = normalizeFontStyle(declarations["font-style"]);
53 const weight = normalizeFontWeight(declarations["font-weight"]);
54 const unicodeRange = normalizeUnicodeRange(declarations["unicode-range"]);
55 faces.push({
56 family,
57 style,
58 weight,
59 display: normalizeDisplay(declarations["font-display"]),
60 stretch: normalizeCssToken(declarations["font-stretch"]),
61 unicodeRange,
62 subsetHint: subsetHintFromUnicodeRange(unicodeRange),
63 source: {
64 type: sourceType,
65 index: sourceIndex,
66 url: sourceUrl,
67 },
68 sources: parseFontSrcList(declarations.src, baseUrl),
69 });
70 }
71 return faces;
72}
73
74export function subsetHintFromUnicodeRange(unicodeRange) {
75 const normalized = normalizeUnicodeRange(unicodeRange);
76 if (!normalized) return "unknown";
77 if (/0E01-0E5B/i.test(normalized)) return "thai";
78 if (/1EA0-1EF9|20AB/i.test(normalized)) return "vietnamese";
79 if (/0000-00FF/i.test(normalized)) return "latin";
80 if (/0100-02BA|1E00-1E9F|A720-A7FF/i.test(normalized)) return "latin-ext";
81 return "other";
82}
83
84export function publicFontUrlFromLocalPath(localPath) {
85 const normalized = String(localPath || "").replace(/\\/g, "/");
86 if (!normalized) return "";
87 return normalized.startsWith("public/") ? `/${normalized.slice("public/".length)}` : normalized;
88}
89
90export async function validateProjectFontFaces({ projectRoot, fontManifest }) {
91 if (!fontManifest?.faces?.length) {
92 return {
93 ok: false,
94 filesScanned: 0,
95 declarationCount: 0,
96 issues: [],
97 warnings: [{ code: "font-manifest-missing", message: "No extracted font manifest was provided." }],
98 };
99 }
100
101 const files = await collectFontSourceFiles(projectRoot);
102 const declarations = [];
103 for (const filePath of files) {
104 const text = await readFile(filePath, "utf8");
105 declarations.push(
106 ...extractFontFaceDeclarations(text, "https://project.local/", {
107 sourceType: "project-source",
108 sourceUrl: filePath,
109 }).map((face) => ({
110 ...face,
111 filePath,
112 })),
113 );
114 }
115
116 const issues = [];
117 const warnings = [];
118 if (!declarations.length) {
119 warnings.push({
120 code: "no-local-font-face-declarations-found",
121 message: `No local @font-face declarations were found under ${path.join(projectRoot, "src")}.`,
122 });
123 }
124
125 const manifestByPublicUrl = new Map();
126 for (const face of fontManifest.faces) {
127 for (const source of face.sources || []) {
128 const publicUrl = publicFontUrlFromLocalPath(source.localPath);
129 if (!publicUrl) continue;
130 if (!manifestByPublicUrl.has(publicUrl)) manifestByPublicUrl.set(publicUrl, []);
131 manifestByPublicUrl.get(publicUrl).push(face);
132 }
133 }
134
135 for (const declaration of declarations) {
136 for (const source of declaration.sources.filter((entry) => entry.kind === "url")) {
137 const publicUrl = normalizeProjectFontUrl(source.url);
138 if (!publicUrl) continue;
139 const manifestFaces = manifestByPublicUrl.get(publicUrl);
140 if (!manifestFaces?.length) {
141 issues.push({
142 code: "untracked-local-font-source",
143 filePath: declaration.filePath,
144 family: declaration.family,
145 style: declaration.style,
146 weight: declaration.weight,
147 sourceUrl: publicUrl,
148 message: `Local font source ${publicUrl} is not tracked in docs/site-clone/fonts.json.`,
149 });
150 continue;
151 }
152
153 const exactMatch = manifestFaces.find((face) =>
154 face.family === declaration.family &&
155 face.style === declaration.style &&
156 face.weight === declaration.weight &&
157 (!declaration.unicodeRange || face.unicodeRange === declaration.unicodeRange),
158 );
159 if (!exactMatch) {
160 issues.push({
161 code: "font-face-metadata-mismatch",
162 filePath: declaration.filePath,
163 family: declaration.family,
164 style: declaration.style,
165 weight: declaration.weight,
166 sourceUrl: publicUrl,
167 expected: manifestFaces.map((face) => ({
168 family: face.family,
169 style: face.style,
170 weight: face.weight,
171 unicodeRange: face.unicodeRange,
172 subsetHint: face.subsetHint,
173 })),
174 message: `Local font source ${publicUrl} is wired to the wrong family/style/weight tuple.`,
175 });
176 continue;
177 }
178
179 if (
180 !declaration.unicodeRange &&
181 fontManifest.contentScripts?.includes("latin") &&
182 exactMatch.subsetHint &&
183 exactMatch.subsetHint !== "unknown" &&
184 exactMatch.subsetHint !== "latin" &&
185 exactMatch.subsetHint !== "latin-ext"
186 ) {
187 issues.push({
188 code: "non-latin-subset-used-without-unicode-range",
189 filePath: declaration.filePath,
190 family: declaration.family,
191 style: declaration.style,
192 weight: declaration.weight,
193 sourceUrl: publicUrl,
194 subsetHint: exactMatch.subsetHint,
195 message: `Local font source ${publicUrl} resolves to the ${exactMatch.subsetHint} subset but the emitted @font-face has no unicode-range guard.`,
196 });
197 }
198 }
199 }
200
201 return {
202 ok: issues.length === 0,
203 filesScanned: files.length,
204 declarationCount: declarations.length,
205 issues,
206 warnings,
207 };
208}
209
210function parseCssDeclarations(block) {
211 const declarations = {};
212 const pattern = /([-\w]+)\s*:\s*([^;]+);?/g;
213 let match;
214 while ((match = pattern.exec(block))) declarations[match[1].toLowerCase()] = match[2].trim();
215 return declarations;
216}
217
218function parseFontSrcList(value, baseUrl) {
219 if (!value) return [];
220 const entries = [];
221 const pattern = /(local|url)\(([^)]+)\)(?:\s*format\(([^)]+)\))?/gi;
222 let match;
223 while ((match = pattern.exec(value))) {
224 const kind = match[1].toLowerCase();
225 const rawValue = stripOuterQuotes(match[2].trim());
226 const format = stripOuterQuotes((match[3] || "").trim()) || "";
227 entries.push({
228 kind,
229 format,
230 value: rawValue,
231 url: kind === "url" ? resolveAssetUrl(rawValue, baseUrl) : "",
232 });
233 }
234 return entries;
235}
236
237function resolveAssetUrl(rawValue, baseUrl) {
238 if (!rawValue) return "";
239 if (rawValue.startsWith("/")) return rawValue;
240 try {
241 return new URL(rawValue, baseUrl).toString();
242 } catch {
243 return rawValue;
244 }
245}
246
247function normalizeProjectFontUrl(value) {
248 if (!value) return "";
249 if (value.startsWith("/")) return value;
250 try {
251 const url = new URL(value);
252 return `${url.pathname}${url.search}`;
253 } catch {
254 return value;
255 }
256}
257
258async function collectFontSourceFiles(projectRoot) {
259 const srcDir = path.join(projectRoot, "src");
260 const files = [];
261 await walk(srcDir, files);
262 return files.sort();
263}
264
265async function walk(dir, files) {
266 let entries;
267 try {
268 entries = await readdir(dir, { withFileTypes: true });
269 } catch {
270 return;
271 }
272 for (const entry of entries) {
273 const fullPath = path.join(dir, entry.name);
274 if (entry.isDirectory()) {
275 await walk(fullPath, files);
276 continue;
277 }
278 if (FONT_SOURCE_FILE_EXTENSIONS.has(path.extname(entry.name))) files.push(fullPath);
279 }
280}
281
282function fontFaceKey(face) {
283 return [
284 face.family,
285 face.style,
286 face.weight,
287 face.unicodeRange,
288 (face.sources || []).map((source) => `${source.kind}:${source.url || source.value}:${source.format}`).join("|"),
289 ].join("::");
290}
291
292function detectContentScripts(text) {
293 const scripts = [];
294 if (/[A-Za-z]/.test(text)) scripts.push("latin");
295 if (/[\u0E00-\u0E7F]/.test(text)) scripts.push("thai");
296 return scripts;
297}
298
299function normalizeFontFamily(value) {
300 return stripOuterQuotes(String(value || "").trim());
301}
302
303function normalizeFontStyle(value) {
304 return normalizeCssToken(value) || "normal";
305}
306
307function normalizeFontWeight(value) {
308 const normalized = normalizeCssToken(value);
309 return normalized || "400";
310}
311
312function normalizeUnicodeRange(value) {
313 return String(value || "").replace(/\s+/g, " ").trim();
314}
315
316function normalizeDisplay(value) {
317 return normalizeCssToken(value) || "swap";
318}
319
320function normalizeCssToken(value) {
321 return String(value || "").trim().toLowerCase();
322}
323
324function stripOuterQuotes(value) {
325 return String(value || "").replace(/^['"]|['"]$/g, "");
326}