@turntrout/subfont 1.0.0

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.
@@ -0,0 +1,20 @@
1
+ const postcssValueParser = require('postcss-value-parser');
2
+
3
+ function extractReferencedCustomPropertyNames(cssValue) {
4
+ const rootNode = postcssValueParser(cssValue);
5
+ const customPropertyNames = new Set();
6
+ for (const node of rootNode.nodes) {
7
+ if (
8
+ node.type === 'function' &&
9
+ node.value === 'var' &&
10
+ node.nodes.length >= 1 &&
11
+ node.nodes[0].type === 'word' &&
12
+ /^--/.test(node.nodes[0].value)
13
+ ) {
14
+ customPropertyNames.add(node.nodes[0].value);
15
+ }
16
+ }
17
+ return customPropertyNames;
18
+ }
19
+
20
+ module.exports = extractReferencedCustomPropertyNames;
@@ -0,0 +1,64 @@
1
+ const parse5 = require('parse5');
2
+
3
+ const INVISIBLE_ELEMENTS = new Set(['script', 'style', 'svg', 'template']);
4
+ const TEXT_ATTRIBUTES = new Set([
5
+ 'alt',
6
+ 'title',
7
+ 'placeholder',
8
+ 'value',
9
+ 'aria-label',
10
+ ]);
11
+
12
+ /**
13
+ * Fast extraction of visible text content from HTML source.
14
+ * Used as a lightweight alternative to full font-tracer for pages
15
+ * that share the same CSS configuration as an already-traced page.
16
+ *
17
+ * Walks the parse5 tree collecting text nodes and content attributes
18
+ * (alt, title, placeholder, value, aria-label), skipping invisible
19
+ * elements (script, style, svg, template).
20
+ */
21
+ function extractVisibleText(html) {
22
+ const document = parse5.parse(html);
23
+ const parts = [];
24
+
25
+ function walk(node) {
26
+ if (node.nodeName && INVISIBLE_ELEMENTS.has(node.nodeName)) {
27
+ return;
28
+ }
29
+
30
+ // Collect relevant attribute values
31
+ if (node.attrs) {
32
+ const isHiddenInput =
33
+ node.nodeName === 'input' &&
34
+ node.attrs.some(
35
+ (a) => a.name === 'type' && a.value.toLowerCase() === 'hidden'
36
+ );
37
+ for (const attr of node.attrs) {
38
+ if (TEXT_ATTRIBUTES.has(attr.name) && attr.value) {
39
+ if (attr.name === 'value' && isHiddenInput) {
40
+ continue;
41
+ }
42
+ parts.push(attr.value);
43
+ }
44
+ }
45
+ }
46
+
47
+ // Collect text content
48
+ if (node.nodeName === '#text' && node.value) {
49
+ parts.push(node.value);
50
+ }
51
+
52
+ // Recurse into child nodes
53
+ if (node.childNodes) {
54
+ for (const child of node.childNodes) {
55
+ walk(child);
56
+ }
57
+ }
58
+ }
59
+
60
+ walk(document);
61
+ return parts.join(' ');
62
+ }
63
+
64
+ module.exports = extractVisibleText;
@@ -0,0 +1,54 @@
1
+ const extractReferencedCustomPropertyNames = require('./extractReferencedCustomPropertyNames');
2
+
3
+ // Find all custom property definitions grouped by the custom properties they contribute to
4
+ function findCustomPropertyDefinitions(cssAssets) {
5
+ const definitionsByProp = {};
6
+ const incomingReferencesByProp = {};
7
+ for (const cssAsset of cssAssets) {
8
+ cssAsset.eachRuleInParseTree((cssRule) => {
9
+ if (
10
+ cssRule.parent.type === 'rule' &&
11
+ cssRule.type === 'decl' &&
12
+ /^--/.test(cssRule.prop)
13
+ ) {
14
+ (definitionsByProp[cssRule.prop] =
15
+ definitionsByProp[cssRule.prop] || new Set()).add(cssRule);
16
+ for (const customPropertyName of extractReferencedCustomPropertyNames(
17
+ cssRule.value
18
+ )) {
19
+ (incomingReferencesByProp[cssRule.prop] =
20
+ incomingReferencesByProp[cssRule.prop] || new Set()).add(
21
+ customPropertyName
22
+ );
23
+ }
24
+ }
25
+ });
26
+ }
27
+ const expandedDefinitionsByProp = {};
28
+ for (const prop of Object.keys(definitionsByProp)) {
29
+ expandedDefinitionsByProp[prop] = new Set();
30
+ const seenProps = new Set();
31
+ const queue = [prop];
32
+ while (queue.length > 0) {
33
+ const referencedProp = queue.shift();
34
+ if (!seenProps.has(referencedProp)) {
35
+ seenProps.add(referencedProp);
36
+ if (definitionsByProp[referencedProp]) {
37
+ for (const cssRule of definitionsByProp[referencedProp]) {
38
+ expandedDefinitionsByProp[prop].add(cssRule);
39
+ }
40
+ }
41
+ const incomingReferences = incomingReferencesByProp[referencedProp];
42
+ if (incomingReferences) {
43
+ for (const incomingReference of incomingReferences) {
44
+ queue.push(incomingReference);
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ return expandedDefinitionsByProp;
52
+ }
53
+
54
+ module.exports = findCustomPropertyDefinitions;
@@ -0,0 +1,292 @@
1
+ const crypto = require('crypto');
2
+ const stripLocalTokens = require('./stripLocalTokens');
3
+ const unicodeRange = require('./unicodeRange');
4
+ const normalizeFontPropertyValue = require('./normalizeFontPropertyValue');
5
+
6
+ const contentTypeByFontFormat = {
7
+ woff: 'font/woff', // https://tools.ietf.org/html/rfc8081#section-4.4.5
8
+ woff2: 'font/woff2',
9
+ truetype: 'font/ttf',
10
+ };
11
+
12
+ function stringifyFontFamily(name) {
13
+ if (/[^a-z0-9_-]/i.test(name)) {
14
+ return `"${name.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
15
+ } else {
16
+ return name;
17
+ }
18
+ }
19
+
20
+ function maybeCssQuote(value) {
21
+ if (/^\w+$/.test(value)) {
22
+ return value;
23
+ } else {
24
+ return `'${value.replace(/'/g, "\\'")}'`;
25
+ }
26
+ }
27
+
28
+ function getPreferredFontUrl(cssFontFaceSrcRelations = []) {
29
+ const formatOrder = ['woff2', 'woff', 'truetype', 'opentype'];
30
+
31
+ const typeOrder = ['Woff2', 'Woff', 'Ttf', 'Otf'];
32
+
33
+ for (const format of formatOrder) {
34
+ const relation = cssFontFaceSrcRelations.find(
35
+ (r) => r.format && r.format.toLowerCase() === format
36
+ );
37
+
38
+ if (relation) {
39
+ return relation.to.url;
40
+ }
41
+ }
42
+
43
+ for (const assetType of typeOrder) {
44
+ const relation = cssFontFaceSrcRelations.find(
45
+ (r) => r.to.type === assetType
46
+ );
47
+
48
+ if (relation) {
49
+ return relation.to.url;
50
+ }
51
+ }
52
+
53
+ return undefined;
54
+ }
55
+
56
+ // Temporarily switch all relation hrefs to absolute so that
57
+ // node.toString() emits fully-qualified URLs in the @font-face src.
58
+ function getFontFaceDeclarationText(node, relations) {
59
+ const originalHrefTypeByRelation = new Map();
60
+ for (const relation of relations) {
61
+ originalHrefTypeByRelation.set(relation, relation.hrefType);
62
+ relation.hrefType = 'absolute';
63
+ }
64
+
65
+ const text = node.toString();
66
+ // Put the hrefTypes that were set to absolute back to their original state:
67
+ for (const [
68
+ relation,
69
+ originalHrefType,
70
+ ] of originalHrefTypeByRelation.entries()) {
71
+ relation.hrefType = originalHrefType;
72
+ }
73
+ return text;
74
+ }
75
+
76
+ const fontOrder = ['woff2', 'woff', 'truetype'];
77
+
78
+ function getFontFaceForFontUsage(fontUsage) {
79
+ const subsets = fontOrder
80
+ .filter((format) => fontUsage.subsets[format])
81
+ .map((format) => ({
82
+ format,
83
+ url: `data:${contentTypeByFontFormat[format]};base64,${fontUsage.subsets[
84
+ format
85
+ ].toString('base64')}`,
86
+ }));
87
+
88
+ const resultString = ['@font-face {'];
89
+
90
+ resultString.push(
91
+ ...Object.keys(fontUsage.props)
92
+ .sort()
93
+ .map((prop) => {
94
+ let value = fontUsage.props[prop];
95
+
96
+ if (prop === 'font-family') {
97
+ value = maybeCssQuote(`${value}__subset`);
98
+ }
99
+
100
+ if (prop === 'src') {
101
+ value = subsets
102
+ .map((subset) => `url(${subset.url}) format('${subset.format}')`)
103
+ .join(', ');
104
+ }
105
+
106
+ return `${prop}: ${value};`;
107
+ })
108
+ .map((str) => ` ${str}`)
109
+ );
110
+
111
+ // Intersect used codepoints with original (font's character set) so
112
+ // the unicode-range only advertises characters actually in the subset.
113
+ // This is essential for unicode-range-split fonts (e.g. CJK) where
114
+ // the text may contain characters outside this font file's range.
115
+ let effectiveUsedCodepoints = fontUsage.codepoints.used;
116
+ if (
117
+ fontUsage.codepoints.original &&
118
+ fontUsage.codepoints.original.length > 0
119
+ ) {
120
+ const originalSet = new Set(fontUsage.codepoints.original);
121
+ const filtered = fontUsage.codepoints.used.filter((cp) =>
122
+ originalSet.has(cp)
123
+ );
124
+ if (filtered.length > 0) {
125
+ effectiveUsedCodepoints = filtered;
126
+ }
127
+ }
128
+ resultString.push(
129
+ ` unicode-range: ${unicodeRange(effectiveUsedCodepoints)};`
130
+ );
131
+
132
+ resultString.push('}');
133
+
134
+ return resultString.join('\n');
135
+ }
136
+
137
+ function getUnusedVariantsStylesheet(
138
+ fontUsages,
139
+ accumulatedFontFaceDeclarations
140
+ ) {
141
+ // Find the available @font-face declarations where the font-family is used
142
+ // (so there will be subsets created), but the specific variant isn't used.
143
+ return accumulatedFontFaceDeclarations
144
+ .filter(
145
+ (decl) =>
146
+ fontUsages.some((fontUsage) =>
147
+ fontUsage.fontFamilies.has(decl['font-family'])
148
+ ) &&
149
+ !fontUsages.some(
150
+ ({ props }) =>
151
+ props['font-style'] === decl['font-style'] &&
152
+ props['font-weight'] === decl['font-weight'] &&
153
+ props['font-stretch'] === decl['font-stretch'] &&
154
+ props['font-family'].toLowerCase() ===
155
+ decl['font-family'].toLowerCase()
156
+ )
157
+ )
158
+ .map((props) => {
159
+ let src = stripLocalTokens(props.src);
160
+ if (props.relations.length > 0) {
161
+ const targets = props.relations.map((relation) => relation.to.url);
162
+ src = src.replace(
163
+ props.relations[0].tokenRegExp,
164
+ () => `url('${targets.shift().replace(/'/g, "\\'")}')`
165
+ );
166
+ }
167
+ let rule = `@font-face{font-family:${maybeCssQuote(`${props['font-family']}__subset`)};font-stretch:${props['font-stretch']};font-style:${props['font-style']};font-weight:${props['font-weight']};src:${src}`;
168
+ if (props['unicode-range']) {
169
+ rule += `;unicode-range:${props['unicode-range']}`;
170
+ }
171
+ // Preserve @font-face metric descriptors used for CLS optimization
172
+ for (const descriptor of [
173
+ 'size-adjust',
174
+ 'ascent-override',
175
+ 'descent-override',
176
+ 'line-gap-override',
177
+ ]) {
178
+ if (props[descriptor]) {
179
+ rule += `;${descriptor}:${props[descriptor]}`;
180
+ }
181
+ }
182
+ rule += '}';
183
+ return rule;
184
+ })
185
+ .join('');
186
+ }
187
+
188
+ function getFontUsageStylesheet(fontUsages) {
189
+ return fontUsages
190
+ .filter((fontUsage) => fontUsage.subsets)
191
+ .map((fontUsage) => getFontFaceForFontUsage(fontUsage))
192
+ .join('');
193
+ }
194
+
195
+ function getCodepoints(text) {
196
+ const codepointSet = new Set();
197
+ for (const char of text) {
198
+ codepointSet.add(char.codePointAt(0));
199
+ }
200
+
201
+ // Make sure that space is always part of the subset fonts (and that it's announced in unicode-range).
202
+ // Prevents Chrome from going off and downloading the fallback:
203
+ // https://gitter.im/assetgraph/assetgraph?at=5f01f6e13a0d3931fad4021b
204
+ codepointSet.add(32);
205
+
206
+ return [...codepointSet];
207
+ }
208
+
209
+ function cssAssetIsEmpty(cssAsset) {
210
+ return cssAsset.parseTree.nodes.every(
211
+ (node) => node.type === 'comment' && !node.text.startsWith('!')
212
+ );
213
+ }
214
+
215
+ function parseFontWeightRange(str) {
216
+ if (typeof str === 'undefined' || str === 'auto') {
217
+ return [-Infinity, Infinity];
218
+ }
219
+ let minFontWeight = 400;
220
+ let maxFontWeight = 400;
221
+ const fontWeightTokens = str.split(/\s+/).map((str) => parseFloat(str));
222
+ if (
223
+ [1, 2].includes(fontWeightTokens.length) &&
224
+ !fontWeightTokens.some(isNaN)
225
+ ) {
226
+ minFontWeight = maxFontWeight = fontWeightTokens[0];
227
+ if (fontWeightTokens.length === 2) {
228
+ maxFontWeight = fontWeightTokens[1];
229
+ }
230
+ }
231
+ return [minFontWeight, maxFontWeight];
232
+ }
233
+
234
+ function parseFontStretchRange(str) {
235
+ if (typeof str === 'undefined' || str.toLowerCase() === 'auto') {
236
+ return [-Infinity, Infinity];
237
+ }
238
+ let minFontStretch = 100;
239
+ let maxFontStretch = 100;
240
+ const fontStretchTokens = str
241
+ .split(/\s+/)
242
+ .map((str) => parseFloat(normalizeFontPropertyValue('font-stretch', str)));
243
+ if (
244
+ [1, 2].includes(fontStretchTokens.length) &&
245
+ !fontStretchTokens.some(isNaN)
246
+ ) {
247
+ minFontStretch = maxFontStretch = fontStretchTokens[0];
248
+ if (fontStretchTokens.length === 2) {
249
+ maxFontStretch = fontStretchTokens[1];
250
+ }
251
+ }
252
+ return [minFontStretch, maxFontStretch];
253
+ }
254
+
255
+ function uniqueChars(text) {
256
+ return [...new Set(text)].sort().join('');
257
+ }
258
+
259
+ function uniqueCharsFromArray(texts) {
260
+ const charSet = new Set();
261
+ for (const text of texts) {
262
+ for (const char of text) {
263
+ charSet.add(char);
264
+ }
265
+ }
266
+ return [...charSet].sort().join('');
267
+ }
268
+
269
+ function hashHexPrefix(stringOrBuffer) {
270
+ return crypto
271
+ .createHash('sha256')
272
+ .update(stringOrBuffer)
273
+ .digest('hex')
274
+ .slice(0, 10);
275
+ }
276
+
277
+ module.exports = {
278
+ stringifyFontFamily,
279
+ maybeCssQuote,
280
+ getPreferredFontUrl,
281
+ getFontFaceDeclarationText,
282
+ getFontFaceForFontUsage,
283
+ getUnusedVariantsStylesheet,
284
+ getFontUsageStylesheet,
285
+ getCodepoints,
286
+ cssAssetIsEmpty,
287
+ parseFontWeightRange,
288
+ parseFontStretchRange,
289
+ uniqueChars,
290
+ uniqueCharsFromArray,
291
+ hashHexPrefix,
292
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Worker thread for running fontTracer in parallel.
3
+ *
4
+ * Receives: { type: 'trace', taskId, htmlText, stylesheetsWithPredicates }
5
+ * Returns: { type: 'result', taskId, textByProps: [{text, props}] }
6
+ *
7
+ * Re-parses HTML with jsdom inside the worker since DOM objects
8
+ * cannot be transferred via structured clone.
9
+ */
10
+
11
+ const { parentPort } = require('worker_threads');
12
+ const { JSDOM } = require('jsdom');
13
+ const postcss = require('postcss');
14
+ const memoizeSync = require('memoizesync');
15
+ const fontTracer = require('font-tracer');
16
+ const getCssRulesByProperty = require('./getCssRulesByProperty');
17
+
18
+ // Each worker gets its own memoized getCssRulesByProperty instance.
19
+ // Since pages on the same site typically share stylesheets, the
20
+ // memoization is effective even within a single worker processing
21
+ // multiple pages sequentially.
22
+ const memoizedGetCssRulesByProperty = memoizeSync(getCssRulesByProperty);
23
+
24
+ parentPort.on('message', (msg) => {
25
+ if (msg.type === 'init') {
26
+ parentPort.postMessage({ type: 'ready' });
27
+ return;
28
+ }
29
+
30
+ if (msg.type === 'trace') {
31
+ try {
32
+ const { taskId, htmlText, stylesheetsWithPredicates: serialized } = msg;
33
+
34
+ // Re-parse HTML with jsdom to get a DOM document
35
+ const dom = new JSDOM(htmlText);
36
+ const document = dom.window.document;
37
+
38
+ // Re-parse CSS from serialized text — asset objects with PostCSS
39
+ // trees can't cross the structured clone boundary.
40
+ const stylesheetsWithPredicates = serialized.map((entry) => ({
41
+ asset: { parseTree: postcss.parse(entry.text) },
42
+ text: entry.text,
43
+ predicates: entry.predicates,
44
+ }));
45
+
46
+ // Run fontTracer — asset is undefined (skips conditional comments
47
+ // and noscript traversal, which is acceptable for modern sites)
48
+ const textByProps = fontTracer(document, {
49
+ stylesheetsWithPredicates,
50
+ getCssRulesByProperty: memoizedGetCssRulesByProperty,
51
+ });
52
+
53
+ // Clean up jsdom to free memory
54
+ dom.window.close();
55
+
56
+ // Strip any non-serializable data from results
57
+ const serializableResults = textByProps.map((entry) => ({
58
+ text: entry.text,
59
+ props: { ...entry.props },
60
+ }));
61
+
62
+ parentPort.postMessage({
63
+ type: 'result',
64
+ taskId,
65
+ textByProps: serializableResults,
66
+ });
67
+ } catch (err) {
68
+ parentPort.postMessage({
69
+ type: 'error',
70
+ taskId: msg.taskId,
71
+ error: err.message,
72
+ stack: err.stack,
73
+ });
74
+ }
75
+ }
76
+ });
@@ -0,0 +1,87 @@
1
+ module.exports = function gatherStylesheetsWithPredicates(
2
+ assetGraph,
3
+ htmlAsset,
4
+ relationIndex
5
+ ) {
6
+ const assetStack = [];
7
+ const incomingMedia = [];
8
+ const conditionalCommentConditionStack = [];
9
+ const result = [];
10
+ (function traverse(asset, isWithinNotIeConditionalComment, isWithinNoscript) {
11
+ if (assetStack.includes(asset)) {
12
+ // Cycle detected
13
+ return;
14
+ } else if (!asset.isLoaded) {
15
+ return;
16
+ }
17
+ assetStack.push(asset);
18
+ // Use pre-built index if available, otherwise fall back to findRelations
19
+ const relations = relationIndex
20
+ ? relationIndex.get(asset) || []
21
+ : assetGraph.findRelations({
22
+ from: asset,
23
+ type: {
24
+ $in: [
25
+ 'HtmlStyle',
26
+ 'SvgStyle',
27
+ 'CssImport',
28
+ 'HtmlConditionalComment',
29
+ 'HtmlNoscript',
30
+ ],
31
+ },
32
+ });
33
+ for (const relation of relations) {
34
+ if (relation.type === 'HtmlNoscript') {
35
+ traverse(relation.to, isWithinNotIeConditionalComment, true);
36
+ } else if (relation.type === 'HtmlConditionalComment') {
37
+ conditionalCommentConditionStack.push(relation.condition);
38
+ traverse(
39
+ relation.to,
40
+ isWithinNotIeConditionalComment ||
41
+ (relation.conditionalComments &&
42
+ relation.conditionalComments.length > 0),
43
+ isWithinNoscript
44
+ );
45
+ conditionalCommentConditionStack.pop();
46
+ } else {
47
+ const media = relation.media;
48
+ if (media) {
49
+ incomingMedia.push(media);
50
+ }
51
+ traverse(
52
+ relation.to,
53
+ isWithinNotIeConditionalComment ||
54
+ (relation.conditionalComments &&
55
+ relation.conditionalComments.length > 0),
56
+ isWithinNoscript
57
+ );
58
+ if (media) {
59
+ incomingMedia.pop();
60
+ }
61
+ }
62
+ }
63
+ assetStack.pop();
64
+ if (asset.type === 'Css') {
65
+ const predicates = {};
66
+ for (const incomingMedium of incomingMedia) {
67
+ predicates[`mediaQuery:${incomingMedium}`] = true;
68
+ }
69
+ for (const conditionalCommentCondition of conditionalCommentConditionStack) {
70
+ predicates[`conditionalComment:${conditionalCommentCondition}`] = true;
71
+ }
72
+ if (isWithinNoscript) {
73
+ predicates.script = false;
74
+ }
75
+ if (isWithinNotIeConditionalComment) {
76
+ predicates['conditionalComment:IE'] = false;
77
+ }
78
+ result.push({
79
+ asset,
80
+ text: asset.text,
81
+ predicates,
82
+ });
83
+ }
84
+ })(htmlAsset);
85
+
86
+ return result;
87
+ };