@@ -39,6 +39,10 @@ const BUILTIN_FONTS = new Set([
3939 "sueellenfrancisco" , "uni neue" , "unineue" , "work sans" , "worksans" ,
4040] ) ;
4141
42+ // Weight/style suffix the render engine strips from a font filename before matching.
43+ const FILENAME_WEIGHT_SUFFIX =
44+ / - (?: (?: E x t r a (?: L i g h t | B o l d ) | S e m i B o l d | R e g u l a r | M e d i u m | L i g h t | B l a c k | B o l d | T h i n | H e a v y ) (?: I t a l i c | O b l i q u e ) ? | (?: I t a l i c | O b l i q u e ) ) $ / i;
45+
4246// Asset types whose `src` must be a public, fetchable URL.
4347const URL_SRC_TYPES = new Set ( [ "image" , "video" , "audio" , "luma" ] ) ;
4448
@@ -93,12 +97,11 @@ function semanticChecks(input: unknown): ValidationIssue[] {
9397 const tracks = timeline ?. tracks ;
9498 if ( ! Array . isArray ( tracks ) ) return issues ;
9599
96- // Custom font families that are actually loaded.
97- const loaded = new Set < string > ( ) ;
100+ const loaded : FontFileNames [ ] = [ ] ;
98101 if ( Array . isArray ( timeline ?. fonts ) ) {
99102 for ( const font of timeline . fonts ) {
100- const base = fontBasename ( font ?. src ) ;
101- if ( base ) loaded . add ( base ) ;
103+ const names = fontFileNames ( font ?. src ) ;
104+ if ( names ) loaded . push ( names ) ;
102105 }
103106 }
104107
@@ -136,17 +139,8 @@ function semanticChecks(input: unknown): ValidationIssue[] {
136139
137140 const family = asset ?. font ?. family ;
138141 if ( typeof family === "string" && family . length > 0 ) {
139- if ( ! loaded . has ( family ) && ! BUILTIN_FONTS . has ( family . toLowerCase ( ) ) ) {
140- issues . push ( {
141- path : `${ at } .asset.font.family` ,
142- code : "font_not_loaded" ,
143- level : "warning" ,
144- message :
145- `font.family '${ family } ' is neither a built-in font nor loaded via ` +
146- `timeline.fonts[] — it will fail at render with "Font not found". Add its ` +
147- `URL to timeline.fonts[] (family must equal the URL's file basename).` ,
148- } ) ;
149- }
142+ const fontIssue = checkFontFamily ( family , loaded , `${ at } .asset.font.family` ) ;
143+ if ( fontIssue ) issues . push ( fontIssue ) ;
150144 }
151145
152146 // --- html5 JS syntax check --------------------------------------------
@@ -191,12 +185,71 @@ function semanticChecks(input: unknown): ValidationIssue[] {
191185 return issues ;
192186}
193187
194- function fontBasename ( src : unknown ) : string | null {
188+ interface FontFileNames {
189+ /** Filename without extension. */
190+ full : string ;
191+ /** Filename without extension or weight suffix ("SpecialElite-Regular" → "SpecialElite"). */
192+ base : string ;
193+ }
194+
195+ // The render engine matches font.family case-insensitively against each loaded font's filename
196+ // or the family name stored inside the file. validate can only see filenames, and the stored name
197+ // can differ from the one on Google Fonts. An unmatched family renders in Roboto without an error,
198+ // unless it is the only loaded font, which is then used whatever font.family says.
199+ function checkFontFamily ( family : string , loaded : FontFileNames [ ] , path : string ) : ValidationIssue | null {
200+ const wanted = family . toLowerCase ( ) ;
201+ if ( BUILTIN_FONTS . has ( wanted ) ) return null ;
202+ if ( loaded . some ( ( f ) => wanted === f . full . toLowerCase ( ) || wanted === f . base . toLowerCase ( ) ) ) return null ;
203+
204+ const storedName =
205+ `the exact family name stored in the font file, which can differ from the name on Google Fonts ` +
206+ `(Space Grotesk's file says 'Space Grotesk Light')` ;
207+
208+ if ( loaded . length === 0 ) {
209+ return {
210+ path,
211+ code : "font_not_loaded" ,
212+ level : "warning" ,
213+ message :
214+ `font.family '${ family } ' is neither a built-in font nor loaded via timeline.fonts[] — ` +
215+ `it renders in Roboto. Add the font's URL to timeline.fonts[] and use its filename as font.family.` ,
216+ } ;
217+ }
218+
219+ if ( loaded . length === 1 ) {
220+ return {
221+ path,
222+ code : "font_not_loaded" ,
223+ level : "warning" ,
224+ message :
225+ `font.family '${ family } ' doesn't match the timeline.fonts[] filename. It renders in that font ` +
226+ `only because it's the sole entry; add another font and it falls back to Roboto unless it's ` +
227+ `${ storedName } . Use the filename to be sure.` ,
228+ suggestion : loaded [ 0 ] ! . full ,
229+ } ;
230+ }
231+
232+ return {
233+ path,
234+ code : "font_not_loaded" ,
235+ level : "warning" ,
236+ message :
237+ `font.family '${ family } ' doesn't match any timeline.fonts[] filename ` +
238+ `(${ loaded . map ( ( f ) => `'${ f . full } '` ) . join ( ", " ) } ). It renders in Roboto unless it's ` +
239+ `${ storedName } . Use the filename to be sure.` ,
240+ } ;
241+ }
242+
243+ function fontFileNames ( src : unknown ) : FontFileNames | null {
195244 if ( typeof src !== "string" ) return null ;
196- const noQuery = src . split ( "?" ) [ 0 ] ?? src ;
197- const base = noQuery . split ( "/" ) . pop ( ) ?? "" ;
198- const stripped = base . replace ( / \. ( t t f | o t f | w o f f 2 ? | e o t ) $ / i, "" ) ;
199- return stripped . length ? stripped : null ;
245+ let filename = src . split ( / [ ? # ] / , 1 ) [ 0 ] ! . split ( "/" ) . pop ( ) ?? "" ;
246+ try {
247+ filename = decodeURIComponent ( filename ) ;
248+ } catch {
249+ // Malformed escape: keep the raw filename, as the render engine does.
250+ }
251+ const full = filename . replace ( / \. ( t t f | o t f | w o f f 2 ? | e o t ) $ / i, "" ) ;
252+ return full . length ? { full, base : full . replace ( FILENAME_WEIGHT_SUFFIX , "" ) } : null ;
200253}
201254
202255function checkJsSyntax ( js : string , path : string ) : ValidationIssue | null {
0 commit comments