@turntrout/subfont 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -352,6 +352,202 @@ function getOrComputeGlobalFontUsages(
352
352
  cached.preloadIndex = textAndPropsToFontUrl;
353
353
  }
354
354
 
355
+ // Extract font tracing (worker pool + sequential) to reduce
356
+ // cyclomatic complexity of collectTextsByPage.
357
+ async function tracePages(
358
+ pagesNeedingFullTrace,
359
+ { headlessBrowser, concurrency, console, memoizedGetCssRulesByProperty }
360
+ ) {
361
+ const useWorkerPool =
362
+ !headlessBrowser &&
363
+ pagesNeedingFullTrace.length >= MIN_PAGES_FOR_WORKER_POOL;
364
+
365
+ if (useWorkerPool) {
366
+ const maxWorkers =
367
+ concurrency > 0 ? concurrency : Math.min(os.cpus().length, 8);
368
+ const numWorkers = Math.min(maxWorkers, pagesNeedingFullTrace.length);
369
+ const pool = new FontTracerPool(numWorkers);
370
+ await pool.init();
371
+
372
+ try {
373
+ const totalPages = pagesNeedingFullTrace.length;
374
+ const showProgress = totalPages >= 10 && console;
375
+ let tracedCount = 0;
376
+ const tracePromises = pagesNeedingFullTrace.map(async (pd) => {
377
+ try {
378
+ pd.textByProps = await pool.trace(
379
+ pd.htmlOrSvgAsset.text || '',
380
+ pd.stylesheetsWithPredicates
381
+ );
382
+ } catch (err) {
383
+ if (console) {
384
+ console.warn(
385
+ `Worker fontTracer failed for ${pd.htmlOrSvgAsset.url}, falling back to main thread: ${err.message}`
386
+ );
387
+ }
388
+ pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
389
+ stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
390
+ getCssRulesByProperty: memoizedGetCssRulesByProperty,
391
+ asset: pd.htmlOrSvgAsset,
392
+ });
393
+ }
394
+ tracedCount++;
395
+ if (showProgress && tracedCount % 10 === 0) {
396
+ console.log(` Tracing fonts: ${tracedCount}/${totalPages} pages...`);
397
+ }
398
+ });
399
+ await Promise.all(tracePromises);
400
+ await pool.destroy();
401
+ } catch (err) {
402
+ await pool.destroy();
403
+ throw err;
404
+ }
405
+ } else if (pagesNeedingFullTrace.length > 0) {
406
+ const totalPages = pagesNeedingFullTrace.length;
407
+ const showProgress = totalPages >= 10 && console;
408
+ for (let pi = 0; pi < totalPages; pi++) {
409
+ const pd = pagesNeedingFullTrace[pi];
410
+ pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
411
+ stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
412
+ getCssRulesByProperty: memoizedGetCssRulesByProperty,
413
+ asset: pd.htmlOrSvgAsset,
414
+ });
415
+ if (headlessBrowser) {
416
+ pd.textByProps.push(
417
+ ...(await headlessBrowser.tracePage(pd.htmlOrSvgAsset))
418
+ );
419
+ }
420
+ if (showProgress && (pi + 1) % 10 === 0) {
421
+ console.log(` Tracing fonts: ${pi + 1}/${totalPages} pages...`);
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ // Extract fast-path text extraction to reduce collectTextsByPage complexity.
428
+ // Pages sharing the same CSS configuration reuse the representative's
429
+ // props and only extract visible text content.
430
+ function processFastPathPages(
431
+ fastPathPages,
432
+ { memoizedGetCssRulesByProperty, console, debug, subTimings }
433
+ ) {
434
+ if (fastPathPages.length === 0) return;
435
+
436
+ const fastPathStart = Date.now();
437
+
438
+ const repDataCache = new Map();
439
+ function getRepData(representativePd) {
440
+ if (repDataCache.has(representativePd)) {
441
+ return repDataCache.get(representativePd);
442
+ }
443
+ const repTextByProps = representativePd.textByProps;
444
+
445
+ const uniquePropsMap = new Map();
446
+ const textPerPropsKey = new Map();
447
+ const seenVariantKeys = new Set();
448
+ for (const entry of repTextByProps) {
449
+ const family = entry.props['font-family'] || '';
450
+ const propsKey = fontPropsKey(
451
+ family,
452
+ entry.props['font-weight'] || '',
453
+ entry.props['font-style'] || '',
454
+ entry.props['font-stretch'] || ''
455
+ );
456
+ if (!uniquePropsMap.has(propsKey)) {
457
+ uniquePropsMap.set(propsKey, entry.props);
458
+ textPerPropsKey.set(propsKey, []);
459
+ }
460
+ textPerPropsKey.get(propsKey).push(entry.text);
461
+ if (family) {
462
+ const weight = entry.props['font-weight'] || 'normal';
463
+ const style = entry.props['font-style'] || 'normal';
464
+ const stretch = entry.props['font-stretch'] || 'normal';
465
+ for (const fam of cssFontParser.parseFontFamily(family)) {
466
+ seenVariantKeys.add(
467
+ fontPropsKey(fam.toLowerCase(), weight, style, stretch)
468
+ );
469
+ }
470
+ }
471
+ }
472
+ const data = { uniquePropsMap, textPerPropsKey, seenVariantKeys };
473
+ repDataCache.set(representativePd, data);
474
+ return data;
475
+ }
476
+
477
+ let fastPathFallbacks = 0;
478
+ for (const pd of fastPathPages) {
479
+ if (hasInlineFontStyles(pd.htmlOrSvgAsset.text || '')) {
480
+ fastPathFallbacks++;
481
+ pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
482
+ stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
483
+ getCssRulesByProperty: memoizedGetCssRulesByProperty,
484
+ asset: pd.htmlOrSvgAsset,
485
+ });
486
+ continue;
487
+ }
488
+
489
+ const { uniquePropsMap, textPerPropsKey, seenVariantKeys } = getRepData(
490
+ pd.representativePd
491
+ );
492
+
493
+ // Check if any @font-face variants are unseen by the representative.
494
+ // Only copy Maps when extensions are actually needed.
495
+ let effectivePropsMap = uniquePropsMap;
496
+ let effectiveTextPerPropsKey = textPerPropsKey;
497
+ for (const decl of pd.accumulatedFontFaceDeclarations) {
498
+ const family = decl['font-family'];
499
+ if (!family) continue;
500
+ const weight = decl['font-weight'] || 'normal';
501
+ const style = decl['font-style'] || 'normal';
502
+ const stretch = decl['font-stretch'] || 'normal';
503
+ const variantKey = fontPropsKey(
504
+ family.toLowerCase(),
505
+ weight,
506
+ style,
507
+ stretch
508
+ );
509
+ if (!seenVariantKeys.has(variantKey)) {
510
+ // Lazy-copy on first unseen variant
511
+ if (effectivePropsMap === uniquePropsMap) {
512
+ effectivePropsMap = new Map(uniquePropsMap);
513
+ effectiveTextPerPropsKey = new Map(textPerPropsKey);
514
+ }
515
+ const propsKey = fontPropsKey(
516
+ stringifyFontFamily(family),
517
+ weight,
518
+ style,
519
+ stretch
520
+ );
521
+ if (!effectivePropsMap.has(propsKey)) {
522
+ effectivePropsMap.set(propsKey, {
523
+ 'font-family': stringifyFontFamily(family),
524
+ 'font-weight': weight,
525
+ 'font-style': style,
526
+ 'font-stretch': stretch,
527
+ });
528
+ effectiveTextPerPropsKey.set(propsKey, []);
529
+ }
530
+ }
531
+ }
532
+
533
+ const pageText = extractVisibleText(pd.htmlOrSvgAsset.text || '');
534
+
535
+ pd.textByProps = [];
536
+ for (const [propsKey, props] of effectivePropsMap) {
537
+ const repTexts = effectiveTextPerPropsKey.get(propsKey) || [];
538
+ pd.textByProps.push({
539
+ text: pageText + repTexts.join(''),
540
+ props: { ...props },
541
+ });
542
+ }
543
+ }
544
+ subTimings['Fast-path extraction'] = Date.now() - fastPathStart;
545
+ if (debug && console)
546
+ console.log(
547
+ `[subfont timing] Fast-path text extraction (${fastPathPages.length} pages, ${fastPathFallbacks} fell back to full trace): ${subTimings['Fast-path extraction']}ms`
548
+ );
549
+ }
550
+
355
551
  async function collectTextsByPage(
356
552
  assetGraph,
357
553
  htmlOrSvgAssets,
@@ -454,39 +650,30 @@ async function collectTextsByPage(
454
650
 
455
651
  if (asset.type === 'Css' && asset.isLoaded) {
456
652
  const seenNodes = new Set();
457
-
458
653
  const fontRelations = asset.outgoingRelations.filter(
459
654
  (relation) => relation.type === 'CssFontFaceSrc'
460
655
  );
461
656
 
462
657
  for (const fontRelation of fontRelations) {
463
658
  const node = fontRelation.node;
464
-
465
- if (!seenNodes.has(node)) {
466
- seenNodes.add(node);
467
-
468
- const fontFaceDeclaration = {
469
- relations: fontRelations.filter((r) => r.node === node),
470
- ...initialValueByProp,
471
- };
472
-
473
- node.walkDecls((declaration) => {
474
- const propName = declaration.prop.toLowerCase();
475
- if (propName === 'font-family') {
476
- fontFaceDeclaration[propName] = cssFontParser.parseFontFamily(
477
- declaration.value
478
- )[0];
479
- } else {
480
- fontFaceDeclaration[propName] = declaration.value;
481
- }
482
- });
483
- // Disregard incomplete @font-face declarations (must contain font-family and src per spec):
484
- if (
485
- fontFaceDeclaration['font-family'] &&
486
- fontFaceDeclaration.src
487
- ) {
488
- accumulatedFontFaceDeclarations.push(fontFaceDeclaration);
489
- }
659
+ if (seenNodes.has(node)) continue;
660
+ seenNodes.add(node);
661
+
662
+ const fontFaceDeclaration = {
663
+ relations: fontRelations.filter((r) => r.node === node),
664
+ ...initialValueByProp,
665
+ };
666
+
667
+ node.walkDecls((declaration) => {
668
+ const propName = declaration.prop.toLowerCase();
669
+ fontFaceDeclaration[propName] =
670
+ propName === 'font-family'
671
+ ? cssFontParser.parseFontFamily(declaration.value)[0]
672
+ : declaration.value;
673
+ });
674
+ // Disregard incomplete @font-face declarations (must contain font-family and src per spec):
675
+ if (fontFaceDeclaration['font-family'] && fontFaceDeclaration.src) {
676
+ accumulatedFontFaceDeclarations.push(fontFaceDeclaration);
490
677
  }
491
678
  }
492
679
  }
@@ -507,24 +694,19 @@ async function collectTextsByPage(
507
694
  // Group @font-face declarations that share family/style/weight but have
508
695
  // different unicode-range values. Each group's members cover a disjoint
509
696
  // subset of the Unicode space (common for CJK / large character-set fonts).
510
- if (accumulatedFontFaceDeclarations.length > 0) {
511
- const comboGroups = new Map();
512
- for (const fontFace of accumulatedFontFaceDeclarations) {
513
- const comboKey = `${fontFace['font-family']}/${fontFace['font-style']}/${fontFace['font-weight']}`;
514
- if (!comboGroups.has(comboKey)) {
515
- comboGroups.set(comboKey, []);
516
- }
517
- comboGroups.get(comboKey).push(fontFace);
518
- }
519
- for (const [comboKey, group] of comboGroups) {
520
- if (group.length > 1) {
521
- const withoutRange = group.filter((d) => !d['unicode-range']);
522
- if (withoutRange.length > 0) {
523
- throw new Error(
524
- `Multiple @font-face with the same font-family/font-style/font-weight combo but missing unicode-range on ${withoutRange.length} of ${group.length} declarations: ${comboKey}`
525
- );
526
- }
527
- }
697
+ const comboGroups = new Map();
698
+ for (const fontFace of accumulatedFontFaceDeclarations) {
699
+ const comboKey = `${fontFace['font-family']}/${fontFace['font-style']}/${fontFace['font-weight']}`;
700
+ if (!comboGroups.has(comboKey)) comboGroups.set(comboKey, []);
701
+ comboGroups.get(comboKey).push(fontFace);
702
+ }
703
+ for (const [comboKey, group] of comboGroups) {
704
+ if (group.length <= 1) continue;
705
+ const withoutRange = group.filter((d) => !d['unicode-range']);
706
+ if (withoutRange.length > 0) {
707
+ throw new Error(
708
+ `Multiple @font-face with the same font-family/font-style/font-weight combo but missing unicode-range on ${withoutRange.length} of ${group.length} declarations: ${comboKey}`
709
+ );
528
710
  }
529
711
  }
530
712
 
@@ -617,75 +799,14 @@ async function collectTextsByPage(
617
799
  `[subfont timing] CSS groups: ${pagesByStylesheetKey.size} unique, ${pagesNeedingFullTrace.length} to trace, ${fastPathPages.length} fast-path`
618
800
  );
619
801
 
620
- // Use worker pool for parallel fontTracer when there are enough pages
621
- const useWorkerPool =
622
- !headlessBrowser &&
623
- pagesNeedingFullTrace.length >= MIN_PAGES_FOR_WORKER_POOL;
624
-
625
802
  const tracingStart = Date.now();
626
803
  try {
627
- if (useWorkerPool) {
628
- const maxWorkers =
629
- concurrency > 0 ? concurrency : Math.min(os.cpus().length, 8);
630
- const numWorkers = Math.min(maxWorkers, pagesNeedingFullTrace.length);
631
- const pool = new FontTracerPool(numWorkers);
632
- await pool.init();
633
-
634
- try {
635
- const totalPages = pagesNeedingFullTrace.length;
636
- const showProgress = totalPages >= 10 && console;
637
- let tracedCount = 0;
638
- const tracePromises = pagesNeedingFullTrace.map(async (pd) => {
639
- try {
640
- pd.textByProps = await pool.trace(
641
- pd.htmlOrSvgAsset.text || '',
642
- pd.stylesheetsWithPredicates
643
- );
644
- } catch (err) {
645
- if (console) {
646
- console.warn(
647
- `Worker fontTracer failed for ${pd.htmlOrSvgAsset.url}, falling back to main thread: ${err.message}`
648
- );
649
- }
650
- pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
651
- stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
652
- getCssRulesByProperty: memoizedGetCssRulesByProperty,
653
- asset: pd.htmlOrSvgAsset,
654
- });
655
- }
656
- tracedCount++;
657
- if (showProgress && tracedCount % 10 === 0) {
658
- console.log(
659
- ` Tracing fonts: ${tracedCount}/${totalPages} pages...`
660
- );
661
- }
662
- });
663
- await Promise.all(tracePromises);
664
- await pool.destroy();
665
- } catch (err) {
666
- await pool.destroy();
667
- throw err;
668
- }
669
- } else if (pagesNeedingFullTrace.length > 0) {
670
- const totalPages = pagesNeedingFullTrace.length;
671
- const showProgress = totalPages >= 10 && console;
672
- for (let pi = 0; pi < totalPages; pi++) {
673
- const pd = pagesNeedingFullTrace[pi];
674
- pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
675
- stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
676
- getCssRulesByProperty: memoizedGetCssRulesByProperty,
677
- asset: pd.htmlOrSvgAsset,
678
- });
679
- if (headlessBrowser) {
680
- pd.textByProps.push(
681
- ...(await headlessBrowser.tracePage(pd.htmlOrSvgAsset))
682
- );
683
- }
684
- if (showProgress && (pi + 1) % 10 === 0) {
685
- console.log(` Tracing fonts: ${pi + 1}/${totalPages} pages...`);
686
- }
687
- }
688
- }
804
+ await tracePages(pagesNeedingFullTrace, {
805
+ headlessBrowser,
806
+ concurrency,
807
+ console,
808
+ memoizedGetCssRulesByProperty,
809
+ });
689
810
 
690
811
  subTimings['Full tracing'] = Date.now() - tracingStart;
691
812
  if (debug && console)
@@ -693,123 +814,12 @@ async function collectTextsByPage(
693
814
  `[subfont timing] Full tracing (${pagesNeedingFullTrace.length} pages): ${subTimings['Full tracing']}ms`
694
815
  );
695
816
 
696
- // Fast-path: for pages sharing CSS with a traced representative,
697
- // reuse the representative's props and extract only the text content.
698
- if (fastPathPages.length > 0) {
699
- const fastPathStart = Date.now();
700
-
701
- const repDataCache = new Map();
702
- function getRepData(representativePd) {
703
- if (repDataCache.has(representativePd)) {
704
- return repDataCache.get(representativePd);
705
- }
706
- const repTextByProps = representativePd.textByProps;
707
-
708
- const uniquePropsMap = new Map();
709
- const textPerPropsKey = new Map();
710
- const seenVariantKeys = new Set();
711
- for (const entry of repTextByProps) {
712
- const family = entry.props['font-family'] || '';
713
- const propsKey = fontPropsKey(
714
- family,
715
- entry.props['font-weight'] || '',
716
- entry.props['font-style'] || '',
717
- entry.props['font-stretch'] || ''
718
- );
719
- if (!uniquePropsMap.has(propsKey)) {
720
- uniquePropsMap.set(propsKey, entry.props);
721
- textPerPropsKey.set(propsKey, []);
722
- }
723
- textPerPropsKey.get(propsKey).push(entry.text);
724
- if (family) {
725
- const weight = entry.props['font-weight'] || 'normal';
726
- const style = entry.props['font-style'] || 'normal';
727
- const stretch = entry.props['font-stretch'] || 'normal';
728
- for (const fam of cssFontParser.parseFontFamily(family)) {
729
- seenVariantKeys.add(
730
- fontPropsKey(fam.toLowerCase(), weight, style, stretch)
731
- );
732
- }
733
- }
734
- }
735
- const data = { uniquePropsMap, textPerPropsKey, seenVariantKeys };
736
- repDataCache.set(representativePd, data);
737
- return data;
738
- }
739
-
740
- let fastPathFallbacks = 0;
741
- for (const pd of fastPathPages) {
742
- if (hasInlineFontStyles(pd.htmlOrSvgAsset.text || '')) {
743
- fastPathFallbacks++;
744
- pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
745
- stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
746
- getCssRulesByProperty: memoizedGetCssRulesByProperty,
747
- asset: pd.htmlOrSvgAsset,
748
- });
749
- continue;
750
- }
751
-
752
- const { uniquePropsMap, textPerPropsKey, seenVariantKeys } = getRepData(
753
- pd.representativePd
754
- );
755
-
756
- // Check if any @font-face variants are unseen by the representative.
757
- // Only copy Maps when extensions are actually needed.
758
- let effectivePropsMap = uniquePropsMap;
759
- let effectiveTextPerPropsKey = textPerPropsKey;
760
- for (const decl of pd.accumulatedFontFaceDeclarations) {
761
- const family = decl['font-family'];
762
- if (!family) continue;
763
- const weight = decl['font-weight'] || 'normal';
764
- const style = decl['font-style'] || 'normal';
765
- const stretch = decl['font-stretch'] || 'normal';
766
- const variantKey = fontPropsKey(
767
- family.toLowerCase(),
768
- weight,
769
- style,
770
- stretch
771
- );
772
- if (!seenVariantKeys.has(variantKey)) {
773
- // Lazy-copy on first unseen variant
774
- if (effectivePropsMap === uniquePropsMap) {
775
- effectivePropsMap = new Map(uniquePropsMap);
776
- effectiveTextPerPropsKey = new Map(textPerPropsKey);
777
- }
778
- const propsKey = fontPropsKey(
779
- stringifyFontFamily(family),
780
- weight,
781
- style,
782
- stretch
783
- );
784
- if (!effectivePropsMap.has(propsKey)) {
785
- effectivePropsMap.set(propsKey, {
786
- 'font-family': stringifyFontFamily(family),
787
- 'font-weight': weight,
788
- 'font-style': style,
789
- 'font-stretch': stretch,
790
- });
791
- effectiveTextPerPropsKey.set(propsKey, []);
792
- }
793
- }
794
- }
795
-
796
- const pageText = extractVisibleText(pd.htmlOrSvgAsset.text || '');
797
-
798
- pd.textByProps = [];
799
- for (const [propsKey, props] of effectivePropsMap) {
800
- const repTexts = effectiveTextPerPropsKey.get(propsKey) || [];
801
- pd.textByProps.push({
802
- text: pageText + repTexts.join(''),
803
- props: { ...props },
804
- });
805
- }
806
- }
807
- subTimings['Fast-path extraction'] = Date.now() - fastPathStart;
808
- if (debug && console)
809
- console.log(
810
- `[subfont timing] Fast-path text extraction (${fastPathPages.length} pages, ${fastPathFallbacks} fell back to full trace): ${subTimings['Fast-path extraction']}ms`
811
- );
812
- }
817
+ processFastPathPages(fastPathPages, {
818
+ memoizedGetCssRulesByProperty,
819
+ console,
820
+ debug,
821
+ subTimings,
822
+ });
813
823
 
814
824
  const assembleStart = Date.now();
815
825
  for (const pd of pageData) {
@@ -24,15 +24,49 @@ const counterRendererNames = new Set([
24
24
  ]);
25
25
 
26
26
  function unwrapNamespace(str) {
27
- if (/^"/.test(str)) {
27
+ if (/^["']/.test(str)) {
28
28
  return unquote(str);
29
29
  } else if (/^url\(.*\)$/i.test(str)) {
30
- return unquote(str.replace(/^url\((.*)\)$/, '$1'));
30
+ return unquote(str.replace(/^url\((.*)\)$/i, '$1'));
31
31
  } else {
32
32
  throw new Error(`Cannot parse CSS namespace: ${str}`);
33
33
  }
34
34
  }
35
35
 
36
+ // Build a collision-free fingerprint for a CSS rule entry. Null bytes (\0)
37
+ // delimit fields because they cannot appear in CSS property values.
38
+ function ruleFingerprint(rule) {
39
+ const predicateEntries = Object.keys(rule.predicates)
40
+ .sort()
41
+ .map((k) => `${k}=${rule.predicates[k]}`);
42
+ return [
43
+ rule.selector,
44
+ rule.value,
45
+ rule.prop,
46
+ rule.important,
47
+ (rule.specificityArray || []).join(','),
48
+ rule.namespaceURI,
49
+ predicateEntries.join('&'),
50
+ ].join('\0');
51
+ }
52
+
53
+ // Remove fully-duplicate rule entries (same selector, value, specificity,
54
+ // predicates, namespace, and importance) within each property.
55
+ function deduplicateRules(rulesByProperty) {
56
+ for (const key of Object.keys(rulesByProperty)) {
57
+ if (key === 'counterStyles' || key === 'keyframes') continue;
58
+ const rules = rulesByProperty[key];
59
+ if (rules.length <= 1) continue;
60
+ const seen = new Set();
61
+ rulesByProperty[key] = rules.filter((rule) => {
62
+ const fp = ruleFingerprint(rule);
63
+ if (seen.has(fp)) return false;
64
+ seen.add(fp);
65
+ return true;
66
+ });
67
+ }
68
+ }
69
+
36
70
  function getCssRulesByProperty(properties, cssSource, existingPredicates) {
37
71
  if (!Array.isArray(properties)) {
38
72
  throw new Error('properties argument must be an array');
@@ -44,13 +78,21 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
44
78
 
45
79
  const parseTree = postcss.parse(cssSource);
46
80
  let defaultNamespaceURI;
81
+ const namespacePrefixes = new Map();
82
+ // Parse @namespace rules: either a default namespace or a prefixed one.
83
+ // Spec: https://developer.mozilla.org/en-US/docs/Web/CSS/@namespace
84
+ // Grammar: @namespace <prefix>? [<string> | url(<uri>)]
47
85
  parseTree.walkAtRules('namespace', (rule) => {
48
- const fragments = rule.params.split(/\s+/);
49
- if (fragments.length === 1) {
50
- defaultNamespaceURI = unwrapNamespace(rule.params);
86
+ const match = rule.params.match(
87
+ /^(?<prefix>\w+)\s+(?<uri>.+)$|^(?<defaultUri>.+)$/
88
+ );
89
+ if (!match) return;
90
+ const { prefix, uri, defaultUri } = match.groups;
91
+ if (prefix) {
92
+ namespacePrefixes.set(prefix, unwrapNamespace(uri));
93
+ } else {
94
+ defaultNamespaceURI = unwrapNamespace(defaultUri);
51
95
  }
52
- // FIXME: Support registering namespace prefixes (fragments.length === 2):
53
- // https://developer.mozilla.org/en-US/docs/Web/CSS/@namespace
54
96
  });
55
97
  const rulesByProperty = {
56
98
  counterStyles: [],
@@ -61,6 +103,33 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
61
103
  rulesByProperty[property] = [];
62
104
  }
63
105
 
106
+ // Resolve the namespace URI for a selector by examining its subject
107
+ // (the rightmost compound selector) for a namespace prefix like svg|text.
108
+ function resolveNamespaceURI(selector) {
109
+ if (namespacePrefixes.size === 0) {
110
+ return defaultNamespaceURI;
111
+ }
112
+ // Find the subject (rightmost simple selector before pseudo-elements).
113
+ // Split on combinators: whitespace, >, +, ~
114
+ const compoundSelectors = selector.split(/\s*[>+~]\s*|\s+/);
115
+ const subject = compoundSelectors[compoundSelectors.length - 1];
116
+ // Check for namespace prefix: prefix|element, *|element, or |element
117
+ const nsMatch = subject.match(/^(?<nsPrefix>\*|\w*)\|/);
118
+ if (!nsMatch) {
119
+ return defaultNamespaceURI;
120
+ }
121
+ const prefix = nsMatch.groups.nsPrefix;
122
+ if (prefix === '*') {
123
+ // *|element matches any namespace — no namespace filter
124
+ return undefined;
125
+ }
126
+ if (prefix === '') {
127
+ // |element means no namespace (elements not in any namespace)
128
+ return '';
129
+ }
130
+ return namespacePrefixes.get(prefix) || defaultNamespaceURI;
131
+ }
132
+
64
133
  const specificityCache = new Map();
65
134
  function getSpecificity(selector) {
66
135
  let cached = specificityCache.get(selector);
@@ -87,12 +156,15 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
87
156
  function pushRulePerSelector(node, prop, value) {
88
157
  getSpecificity(node.parent.selector).forEach((specificityObject) => {
89
158
  const isStyleAttribute = specificityObject.selector === 'bogusselector';
159
+ const selectorStr = isStyleAttribute
160
+ ? undefined
161
+ : specificityObject.selector.trim();
90
162
  (rulesByProperty[prop] = rulesByProperty[prop] || []).push({
91
163
  predicates: getCurrentPredicates(),
92
- namespaceURI: defaultNamespaceURI,
93
- selector: isStyleAttribute
94
- ? undefined
95
- : specificityObject.selector.trim(),
164
+ namespaceURI: isStyleAttribute
165
+ ? defaultNamespaceURI
166
+ : resolveNamespaceURI(selectorStr),
167
+ selector: selectorStr,
96
168
  specificityArray: isStyleAttribute
97
169
  ? [1, 0, 0, 0]
98
170
  : specificityObject.specificityArray,
@@ -198,12 +270,15 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
198
270
  getSpecificity(node.parent.selector).forEach((specificityObject) => {
199
271
  const isStyleAttribute =
200
272
  specificityObject.selector === 'bogusselector';
273
+ const fontSelector = isStyleAttribute
274
+ ? undefined
275
+ : specificityObject.selector.trim();
201
276
  const entry = {
202
277
  predicates: getCurrentPredicates(),
203
- namespaceURI: defaultNamespaceURI,
204
- selector: isStyleAttribute
205
- ? undefined
206
- : specificityObject.selector.trim(),
278
+ namespaceURI: isStyleAttribute
279
+ ? defaultNamespaceURI
280
+ : resolveNamespaceURI(fontSelector),
281
+ selector: fontSelector,
207
282
  specificityArray: isStyleAttribute
208
283
  ? [1, 0, 0, 0]
209
284
  : specificityObject.specificityArray,
@@ -261,7 +336,7 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
261
336
  }
262
337
  })(parseTree);
263
338
 
264
- // TODO: Collapse into a single object for duplicate values?
339
+ deduplicateRules(rulesByProperty);
265
340
 
266
341
  return rulesByProperty;
267
342
  }