single-file-core 1.5.48 → 1.5.49

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,119 @@
1
+ /*
2
+ * Copyright 2010-2025 Gildas Lormeau
3
+ * contact : gildas.lormeau <at> gmail.com
4
+ *
5
+ * This file is part of SingleFile.
6
+ *
7
+ * The code in this file is free software: you can redistribute it and/or
8
+ * modify it under the terms of the GNU Affero General Public License
9
+ * (GNU AGPL) as published by the Free Software Foundation, either version 3
10
+ * of the License, or (at your option) any later version.
11
+ *
12
+ * The code in this file is distributed in the hope that it will be useful,
13
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
15
+ * General Public License for more details.
16
+ *
17
+ * As additional permission under GNU AGPL version 3 section 7, you may
18
+ * distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
19
+ * AGPL normally required by section 4, provided you include this license
20
+ * notice and a URL through which recipients can access the Corresponding
21
+ * Source.
22
+ */
23
+
24
+ import * as cssTree from "./../vendor/css-tree.js";
25
+
26
+ const CANONICAL_PSEUDO_ELEMENT_NAMES = new Set(["after", "before", "first-letter", "first-line", "placeholder", "selection", "part", "marker"]);
27
+
28
+ export {
29
+ parsePrelude
30
+ };
31
+
32
+ function parsePrelude(prelude) {
33
+ if (!prelude) {
34
+ return { include: [], exclude: [] };
35
+ }
36
+
37
+ // Normalize prelude to a string then split on a top-level `to` keyword.
38
+ // Using generated string is pragmatic: `to` as an at-rule keyword is expected
39
+ // to appear at top-level with surrounding whitespace. We split on whitespace+to+whitespace.
40
+ const preludeText = cssTree.generate(prelude).trim();
41
+ if (!preludeText) return { include: [], exclude: [] };
42
+
43
+ // Split on top-level ' to ' (case-insensitive) — join remaining parts if multiple 'to' appear
44
+ const parts = preludeText.split(/\s+to\s+/i);
45
+ const includeText = parts[0].trim();
46
+ const excludeText = parts.length > 1 ? parts.slice(1).join(" to ").trim() : "";
47
+
48
+ function parseSelectorList(text) {
49
+ if (!text) return [];
50
+ // Strip balanced outer parentheses that css-tree may produce in generated preludes
51
+ function stripOuterParens(s) {
52
+ let str = s.trim();
53
+ while (str.length >= 2 && str[0] === "(" && str[str.length - 1] === ")") {
54
+ // ensure they are balanced pairs for the whole string
55
+ let depth = 0;
56
+ let balanced = true;
57
+ for (let i = 0; i < str.length; i++) {
58
+ if (str[i] === "(") depth++;
59
+ else if (str[i] === ")") depth--;
60
+ if (depth === 0 && i < str.length - 1) { balanced = false; break; }
61
+ }
62
+ if (!balanced) break;
63
+ str = str.substring(1, str.length - 1).trim();
64
+ }
65
+ return str;
66
+ }
67
+
68
+ const cleaned = stripOuterParens(text);
69
+ // css-tree expects a selectorList context
70
+ const ast = cssTree.parse(cleaned, { context: "selectorList" });
71
+ const selectors = [];
72
+ if (ast && ast.children) {
73
+ for (let node = ast.children.head; node; node = node.next) {
74
+ const sel = node.data;
75
+ selectors.push({ ast: sel, text: cssTree.generate(sel) });
76
+ }
77
+ }
78
+ return selectors;
79
+ }
80
+
81
+ const include = parseSelectorList(includeText);
82
+ const exclude = parseSelectorList(excludeText);
83
+
84
+ // Validate: pseudo-elements are not allowed in scope start/end selectors
85
+ function containsPseudoElement(selectorAst) {
86
+ let found = false;
87
+ cssTree.walk(selectorAst, {
88
+ visit: "PseudoElementSelector",
89
+ enter() { found = true; }
90
+ });
91
+ if (!found) {
92
+ // also check for pseudo-class names that are treated as pseudo-elements by some authors
93
+ cssTree.walk(selectorAst, {
94
+ visit: "PseudoClassSelector",
95
+ enter(node) {
96
+ const name = (node.name || "").toLowerCase();
97
+ // keep this conservative: disallow known pseudo-element names if used as pseudo-class
98
+ if (CANONICAL_PSEUDO_ELEMENT_NAMES.has(name)) {
99
+ found = true;
100
+ }
101
+ }
102
+ });
103
+ }
104
+ return found;
105
+ }
106
+
107
+ for (const s of include) {
108
+ if (containsPseudoElement(s.ast)) {
109
+ throw new Error("Pseudo-elements are not allowed in @scope prelude (scope-start)");
110
+ }
111
+ }
112
+ for (const s of exclude) {
113
+ if (containsPseudoElement(s.ast)) {
114
+ throw new Error("Pseudo-elements are not allowed in @scope prelude (scope-end)");
115
+ }
116
+ }
117
+
118
+ return { include, exclude };
119
+ }
@@ -0,0 +1,106 @@
1
+ /*
2
+ /*
3
+ * Copyright 2010-2025 Gildas Lormeau
4
+ * contact : gildas.lormeau <at> gmail.com
5
+ *
6
+ * This file is part of SingleFile.
7
+ *
8
+ * The code in this file is free software: you can redistribute it and/or
9
+ * modify it under the terms of the GNU Affero General Public License
10
+ * (GNU AGPL) as published by the Free Software Foundation, either version 3
11
+ * of the License, or (at your option) any later version.
12
+ *
13
+ * The code in this file is distributed in the hope that it will be useful,
14
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
16
+ * General Public License for more details.
17
+ *
18
+ * As additional permission under GNU AGPL version 3 section 7, you may
19
+ * distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
20
+ * AGPL normally required by section 4, provided you include this license
21
+ * notice and a URL through which recipients can access the Corresponding
22
+ * Source.
23
+ */
24
+
25
+ import * as cssTree from "./../vendor/css-tree.js";
26
+
27
+ const UNMATCHABLE_PSEUDO_CLASSES = [
28
+ "active-view-transition",
29
+ "active-view-transition-type",
30
+ "blank",
31
+ "buffering",
32
+ "current",
33
+ "first",
34
+ "future",
35
+ "has-slotted",
36
+ "host-context",
37
+ "heading",
38
+ "left",
39
+ "muted",
40
+ "open",
41
+ "past",
42
+ "paused",
43
+ "picture-in-picture",
44
+ "playing",
45
+ "right",
46
+ "seeking",
47
+ "stalled",
48
+ "volume-locked",
49
+ ];
50
+
51
+ export {
52
+ sanitizeSelector,
53
+ };
54
+
55
+ /**
56
+ * Sanitize a selector AST into a QSA-safe selector string.
57
+ * Optional `ancestors` array may be provided to expand nesting selectors (`&`).
58
+ */
59
+ function sanitizeSelector(selector, ancestors, docContext) {
60
+ if (!docContext.normalizedSelectorText) docContext.normalizedSelectorText = new WeakMap();
61
+ if (docContext.normalizedSelectorText.has(selector)) {
62
+ return docContext.normalizedSelectorText.get(selector);
63
+ }
64
+ const ast = cssTree.clone(selector.data);
65
+ normalizeSelectorNode(ast, ancestors);
66
+ let normalized = cssTree.generate(ast);
67
+ if (!normalized || !normalized.trim()) {
68
+ normalized = "*";
69
+ }
70
+ docContext.normalizedSelectorText.set(selector, normalized);
71
+ return normalized;
72
+ }
73
+
74
+ function normalizeSelectorNode(selector, ancestors) {
75
+ let current = selector.children.head;
76
+ while (current) {
77
+ const next = current.next;
78
+ const childNode = current.data;
79
+ if (childNode.type === "NestingSelector") {
80
+ if (ancestors && ancestors.length) {
81
+ const lastAncestor = ancestors[ancestors.length - 1];
82
+ let ancestorAst = lastAncestor && lastAncestor.data ? lastAncestor.data : lastAncestor;
83
+ if (ancestorAst && ancestorAst.type === "SelectorList" && ancestorAst.children && ancestorAst.children.tail) {
84
+ ancestorAst = ancestorAst.children.tail.data;
85
+ }
86
+ if (ancestorAst && ancestorAst.children) {
87
+ for (let a = ancestorAst.children.head; a; a = a.next) {
88
+ const cloned = cssTree.clone(a.data);
89
+ selector.children.insertData(cloned, current);
90
+ }
91
+ selector.children.remove(current);
92
+ }
93
+ }
94
+ } else if (childNode.type === "TypeSelector" && typeof childNode.name === "string" && childNode.name.includes("|")) {
95
+ childNode.name = childNode.name.substring(childNode.name.lastIndexOf("|") + 1);
96
+ } else if (childNode.type === "PseudoElementSelector") {
97
+ selector.children.remove(current);
98
+ } else if (childNode.type === "PseudoClassSelector") {
99
+ const pseudoName = (childNode.name || "").toLowerCase();
100
+ if (UNMATCHABLE_PSEUDO_CLASSES.includes(pseudoName)) {
101
+ selector.children.remove(current);
102
+ }
103
+ }
104
+ current = next;
105
+ }
106
+ }
@@ -0,0 +1,211 @@
1
+ /*
2
+ * Copyright 2010-2025 Gildas Lormeau
3
+ * contact : gildas.lormeau <at> gmail.com
4
+ *
5
+ * This file is part of SingleFile.
6
+ *
7
+ * The code in this file is free software: you can redistribute it and/or
8
+ * modify it under the terms of the GNU Affero General Public License
9
+ * (GNU AGPL) as published by the Free Software Foundation, either version 3
10
+ * of the License, or (at your option) any later version.
11
+ *
12
+ * The code in this file is distributed in the hope that it will be useful,
13
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
15
+ * General Public License for more details.
16
+ *
17
+ * As additional permission under GNU AGPL version 3 section 7, you may
18
+ * distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
19
+ * AGPL normally required by section 4, provided you include this license
20
+ * notice and a URL through which recipients can access the Corresponding
21
+ * Source.
22
+ */
23
+
24
+ import * as cssTree from "./../vendor/css-tree.js";
25
+
26
+ export {
27
+ computeSpecificity,
28
+ computeMaxSpecificity
29
+ };
30
+
31
+ function computeSpecificity(selector, specificity = { a: 0, b: 0, c: 0 }) {
32
+ if (!selector || !selector.type) {
33
+ return specificity;
34
+ }
35
+ switch (selector.type) {
36
+ case "Selector":
37
+ traverseChildren(selector.children, (child) => computeSpecificity(child, specificity));
38
+ break;
39
+
40
+ case "IdSelector":
41
+ specificity.a++;
42
+ break;
43
+
44
+ case "ClassSelector":
45
+ specificity.b++;
46
+ break;
47
+
48
+ case "AttributeSelector":
49
+ specificity.b++;
50
+ break;
51
+
52
+ case "TypeSelector":
53
+ if (selector.name !== "*") {
54
+ specificity.c++;
55
+ }
56
+ break;
57
+
58
+ case "PseudoElementSelector":
59
+ specificity.c++;
60
+ break;
61
+
62
+ case "PseudoClassSelector": {
63
+ const pseudoName = selector.name.toLowerCase();
64
+
65
+ if (pseudoName === "where") {
66
+ // :where() has zero specificity - do nothing
67
+ break;
68
+ }
69
+
70
+ if (pseudoName === "is" || pseudoName === "not" || pseudoName === "has") {
71
+ // :is(), :not(), :has() - use the max specificity from their selector list
72
+ traverseChildren(selector.children, (child) => {
73
+ if (child.type === "SelectorList") {
74
+ addMaxSpecificity(specificity, getMaxSpecificityFromList(child));
75
+ }
76
+ });
77
+ break;
78
+ }
79
+
80
+ if (pseudoName === "nth-child" || pseudoName === "nth-last-child") {
81
+ // :nth-child() and :nth-last-child() count as one pseudo-class
82
+ specificity.b++;
83
+
84
+ // Plus the max specificity from their selector list (if any)
85
+ traverseChildren(selector.children, (child) => {
86
+ if (child.type === "Nth" && child.selector) {
87
+ addMaxSpecificity(specificity, getMaxSpecificityFromList(child.selector));
88
+ }
89
+ });
90
+ break;
91
+ }
92
+
93
+ // Regular pseudo-classes contribute to 'b'
94
+ // Exception: :scope is treated as a type selector (contributes to 'c')
95
+ if (pseudoName === "scope") {
96
+ specificity.c++;
97
+ } else {
98
+ specificity.b++;
99
+ }
100
+ break;
101
+ }
102
+
103
+ case "Combinator":
104
+ case "Raw":
105
+ break;
106
+ }
107
+
108
+ return specificity;
109
+ }
110
+
111
+ function addMaxSpecificity(specificity, maxSpec) {
112
+ specificity.a += maxSpec.a;
113
+ specificity.b += maxSpec.b;
114
+ specificity.c += maxSpec.c;
115
+ }
116
+
117
+ function traverseChildren(children, callback) {
118
+ if (!children) return;
119
+
120
+ let current = children.head;
121
+ while (current) {
122
+ callback(current.data);
123
+ current = current.next;
124
+ }
125
+ }
126
+
127
+ function getMaxSpecificityFromList(selectorList) {
128
+ let maxSpec = { a: 0, b: 0, c: 0 };
129
+
130
+ traverseChildren(selectorList.children, (selector) => {
131
+ const spec = computeSpecificity(selector, { a: 0, b: 0, c: 0 });
132
+ if (spec.a > maxSpec.a ||
133
+ (spec.a === maxSpec.a && spec.b > maxSpec.b) ||
134
+ (spec.a === maxSpec.a && spec.b === maxSpec.b && spec.c > maxSpec.c)) {
135
+ maxSpec = spec;
136
+ }
137
+ });
138
+
139
+ return maxSpec;
140
+ }
141
+
142
+ function computeMaxSpecificity(selector, ancestorsSelectors) {
143
+ // If no ancestors provided, keep existing behavior
144
+ if (!ancestorsSelectors || !ancestorsSelectors.length) {
145
+ let maxSpecificity = { a: 0, b: 0, c: 0 };
146
+ const stack = [];
147
+ cssTree.walk(selector, {
148
+ enter(node) {
149
+ stack.push(node);
150
+ if (node.type === "Selector") {
151
+ const insideWhere = stack.some(n => n.type === "PseudoClassSelector" && n.name === "where");
152
+ if (insideWhere) return;
153
+ const specificity = computeSpecificity(node);
154
+ if (specificity.a > maxSpecificity.a ||
155
+ (specificity.a === maxSpecificity.a && specificity.b > maxSpecificity.b) ||
156
+ (specificity.a === maxSpecificity.a && specificity.b === maxSpecificity.b && specificity.c > maxSpecificity.c)) {
157
+ maxSpecificity = specificity;
158
+ }
159
+ }
160
+ },
161
+ leave() {
162
+ stack.pop();
163
+ }
164
+ });
165
+ return maxSpecificity;
166
+ }
167
+
168
+ // When ancestors are provided, compute specificity for every expanded selector
169
+ // Build context strings for ancestors (similar to combineWithAncestors behavior)
170
+ const childText = cssTree.generate(selector);
171
+ let contexts = [""];
172
+ ancestorsSelectors.forEach(selectorList => {
173
+ if (!selectorList || !selectorList.children || !selectorList.children.size) return;
174
+ const parentSelectors = selectorList.children.toArray();
175
+ const nextContexts = [];
176
+ contexts.forEach(context => parentSelectors.forEach(parentSelector => {
177
+ const parentText = cssTree.generate(parentSelector);
178
+ const combined = context ? context + " " + parentText : parentText;
179
+ if (!nextContexts.includes(combined)) nextContexts.push(combined);
180
+ }));
181
+ if (nextContexts.length) contexts = nextContexts;
182
+ });
183
+
184
+ function combineStrings(context, child) {
185
+ if (!context) return child;
186
+ if (!child) return context;
187
+ if (child.indexOf("&") !== -1) return child.split("&").join(context);
188
+ return context + " " + child;
189
+ }
190
+
191
+ let maxSpecificity = { a: 0, b: 0, c: 0 };
192
+ const seen = new Set();
193
+ contexts.forEach(context => {
194
+ const full = combineStrings(context, childText);
195
+ if (seen.has(full)) return;
196
+ seen.add(full);
197
+ try {
198
+ const parsed = cssTree.parse(full, { context: "selectorList" });
199
+ // reuse original logic to compute max specificity over parsed AST
200
+ const spec = computeMaxSpecificity(parsed);
201
+ if (spec.a > maxSpecificity.a ||
202
+ (spec.a === maxSpecificity.a && spec.b > maxSpecificity.b) ||
203
+ (spec.a === maxSpecificity.a && spec.b === maxSpecificity.b && spec.c > maxSpecificity.c)) {
204
+ maxSpecificity = spec;
205
+ }
206
+ } catch {
207
+ // ignore parse errors and continue
208
+ }
209
+ });
210
+ return maxSpecificity;
211
+ }
@@ -147,17 +147,22 @@ function serializeAttribute(attribute, element, compressHTML) {
147
147
  }
148
148
  const invalidUnquotedValue = !compressHTML || value.match(/[ \t\n\f\r'"`=<>]/);
149
149
  content += " ";
150
- if (!attribute.namespace) {
150
+ const namespaceURI = attribute.namespaceURI;
151
+ const localName = attribute.localName || name;
152
+ if (!namespaceURI) {
151
153
  content += name;
152
- } else if (attribute.namespaceURI == "http://www.w3.org/XML/1998/namespace") {
153
- content += "xml:" + name;
154
- } else if (attribute.namespaceURI == "http://www.w3.org/2000/xmlns/") {
155
- if (name !== "xmlns") {
156
- content += "xmlns:";
154
+ } else if (namespaceURI == "http://www.w3.org/XML/1998/namespace") {
155
+ content += "xml:" + localName;
156
+ } else if (namespaceURI == "http://www.w3.org/2000/xmlns/") {
157
+ if (localName === "xmlns") {
158
+ content += "xmlns";
159
+ } else {
160
+ content += "xmlns:" + localName;
157
161
  }
158
- content += name;
159
- } else if (attribute.namespaceURI == "http://www.w3.org/1999/xlink") {
160
- content += "xlink:" + name;
162
+ } else if (namespaceURI == "http://www.w3.org/1999/xlink") {
163
+ content += "xlink:" + localName;
164
+ } else if (attribute.prefix) {
165
+ content += attribute.prefix + ":" + localName;
161
166
  } else {
162
167
  content += name;
163
168
  }
package/modules/index.js CHANGED
@@ -22,7 +22,6 @@
22
22
  */
23
23
 
24
24
  import * as fontsMinifier from "./css-fonts-minifier.js";
25
- import * as matchedRules from "./css-matched-rules.js";
26
25
  import * as mediasAltMinifier from "./css-medias-alt-minifier.js";
27
26
  import * as cssRulesMinifier from "./css-rules-minifier.js";
28
27
  import * as imagesAltMinifier from "./html-images-alt-minifier.js";
@@ -32,7 +31,6 @@ import * as templateFormatter from "./template-formatter.js";
32
31
 
33
32
  export {
34
33
  fontsMinifier,
35
- matchedRules,
36
34
  mediasAltMinifier,
37
35
  cssRulesMinifier,
38
36
  imagesAltMinifier,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.48",
3
+ "version": "1.5.49",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -16,6 +16,6 @@
16
16
  },
17
17
  "homepage": "https://github.com/gildas-lormeau/single-file-core#readme",
18
18
  "devDependencies": {
19
- "eslint": "^9.20.1"
19
+ "eslint": "^9.38.0"
20
20
  }
21
21
  }