@turntrout/subfont 1.0.2 → 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.
@@ -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
  }
@@ -3,6 +3,34 @@ const initialValueByProp = require('./initialValueByProp');
3
3
  const unquote = require('./unquote');
4
4
  const normalizeFontStretch = require('font-snapper/lib/normalizeFontStretch');
5
5
 
6
+ // Resolve the CSS relative font-weight keyword `bolder` according to CSS Fonts Level 4.
7
+ // https://www.w3.org/TR/css-fonts-4/#relative-weights
8
+ function applyBolder(weight) {
9
+ if (weight < 350) return 400;
10
+ if (weight < 550) return 700;
11
+ return 900;
12
+ }
13
+
14
+ // Resolve the CSS relative font-weight keyword `lighter` according to CSS Fonts Level 4.
15
+ function applyLighter(weight) {
16
+ if (weight < 100) return weight;
17
+ if (weight < 550) return 100;
18
+ if (weight < 750) return 400;
19
+ return 700;
20
+ }
21
+
22
+ // Resolve a font-weight name (e.g. "bold") or numeric string to a number.
23
+ // Returns NaN if the value is not a recognized weight.
24
+ function resolveWeightToken(token) {
25
+ const named = cssFontWeightNames[token];
26
+ return parseFloat(named !== undefined ? named : token);
27
+ }
28
+
29
+ // Return true when the numeric weight is within the CSS spec range [1, 1000].
30
+ function isValidWeight(weight) {
31
+ return weight >= 1 && weight <= 1000;
32
+ }
33
+
6
34
  function normalizeFontPropertyValue(propName, value) {
7
35
  const propNameLowerCase = propName.toLowerCase();
8
36
  if (value === undefined) {
@@ -13,16 +41,29 @@ function normalizeFontPropertyValue(propName, value) {
13
41
  } else if (propNameLowerCase === 'font-weight') {
14
42
  let parsedValue = value;
15
43
  if (typeof parsedValue === 'string') {
16
- // FIXME: Stripping the +bolder... suffix here will not always yield the correct result
17
- // when expanding animations and transitions
18
- parsedValue = parsedValue.replace(/\+.*$/, '').toLowerCase();
44
+ // font-tracer encodes relative weight modifiers as "baseWeight+bolder+lighter+..."
45
+ // Apply each modifier sequentially per the CSS Fonts Level 4 spec.
46
+ const segments = parsedValue.split('+');
47
+ const baseToken = segments[0].toLowerCase();
48
+ let baseWeight = resolveWeightToken(baseToken);
49
+ if (isValidWeight(baseWeight) && segments.length > 1) {
50
+ for (let i = 1; i < segments.length; i++) {
51
+ const modifier = segments[i].toLowerCase();
52
+ if (modifier === 'bolder') {
53
+ baseWeight = applyBolder(baseWeight);
54
+ } else if (modifier === 'lighter') {
55
+ baseWeight = applyLighter(baseWeight);
56
+ }
57
+ }
58
+ return baseWeight;
59
+ }
60
+ parsedValue = baseToken;
19
61
  }
20
- parsedValue = parseFloat(cssFontWeightNames[parsedValue] || parsedValue);
21
- if (parsedValue >= 1 && parsedValue <= 1000) {
22
- return parsedValue;
23
- } else {
24
- return value;
62
+ const numericWeight = resolveWeightToken(parsedValue);
63
+ if (isValidWeight(numericWeight)) {
64
+ return numericWeight;
25
65
  }
66
+ return value;
26
67
  } else if (propNameLowerCase === 'font-stretch') {
27
68
  return normalizeFontStretch(value);
28
69
  } else if (typeof value === 'string' && propNameLowerCase !== 'src') {
@@ -854,7 +854,6 @@ async function subsetFonts(
854
854
  for (const htmlOrSvgAsset of htmlOrSvgAssets) {
855
855
  const accumulatedFontFaceDeclarations =
856
856
  fontFaceDeclarationsByHtmlOrSvgAsset.get(htmlOrSvgAsset);
857
- // TODO: Maybe group by media?
858
857
  const containedRelationsByFontFaceRule = new Map();
859
858
  for (const { relations } of accumulatedFontFaceDeclarations) {
860
859
  for (const relation of relations) {
@@ -882,14 +881,42 @@ async function subsetFonts(
882
881
  continue;
883
882
  }
884
883
 
885
- const fallbackCssText = [...containedRelationsByFontFaceRule.keys()]
886
- .map((rule) =>
887
- getFontFaceDeclarationText(
888
- rule,
889
- containedRelationsByFontFaceRule.get(rule)
890
- )
891
- )
892
- .join('');
884
+ // Group @font-face rules by their enclosing @media context so the
885
+ // fallback CSS preserves the original media-conditional loading.
886
+ // Walk up the ancestor chain in case the rule is nested (e.g.
887
+ // inside @supports inside @media).
888
+ const rulesByMedia = new Map(); // media params (or '') → [cssText, ...]
889
+ for (const rule of containedRelationsByFontFaceRule.keys()) {
890
+ let mediaKey = '';
891
+ let ancestor = rule.parent;
892
+ while (ancestor) {
893
+ if (
894
+ ancestor.type === 'atrule' &&
895
+ ancestor.name.toLowerCase() === 'media'
896
+ ) {
897
+ mediaKey = ancestor.params;
898
+ break;
899
+ }
900
+ ancestor = ancestor.parent;
901
+ }
902
+ if (!rulesByMedia.has(mediaKey)) rulesByMedia.set(mediaKey, []);
903
+ rulesByMedia
904
+ .get(mediaKey)
905
+ .push(
906
+ getFontFaceDeclarationText(
907
+ rule,
908
+ containedRelationsByFontFaceRule.get(rule)
909
+ )
910
+ );
911
+ }
912
+ let fallbackCssText = '';
913
+ for (const [media, texts] of rulesByMedia) {
914
+ if (media) {
915
+ fallbackCssText += `@media ${media}{${texts.join('')}}`;
916
+ } else {
917
+ fallbackCssText += texts.join('');
918
+ }
919
+ }
893
920
 
894
921
  let cssAsset = fallbackCssAssetCache.get(fallbackCssText);
895
922
  if (!cssAsset) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turntrout/subfont",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Automatically subset web fonts to only the characters used on your pages. Fork of Munter/subfont with modern defaults.",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"