@segnavia/html 0.26.0 → 0.28.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.
- package/dist/base64.d.ts +4 -0
- package/dist/base64.js +51 -0
- package/dist/bindings.d.ts +4 -1
- package/dist/bindings.js +19 -2
- package/dist/book/book-document.js +146 -65
- package/dist/book/book-held.js +182 -0
- package/dist/book/book-view.d.ts +5 -0
- package/dist/book/book-view.js +26 -0
- package/dist/book/furniture.d.ts +14 -0
- package/dist/book/furniture.js +36 -0
- package/dist/book/notes.js +18 -9
- package/dist/book/paginate-book.d.ts +11 -0
- package/dist/book/paginate-book.js +195 -0
- package/dist/book/paginate-columns.d.ts +8 -0
- package/dist/book/paginate-columns.js +119 -0
- package/dist/book/paginate-keeps.d.ts +4 -0
- package/dist/book/paginate-keeps.js +80 -0
- package/dist/book/paginate-marks.d.ts +7 -0
- package/dist/book/paginate-marks.js +34 -0
- package/dist/book/paginate-notes.d.ts +21 -0
- package/dist/book/paginate-notes.js +105 -0
- package/dist/book/paginate-sequence.d.ts +4 -0
- package/dist/book/paginate-sequence.js +267 -0
- package/dist/book/paginate-target.d.ts +13 -0
- package/dist/book/paginate-target.js +36 -0
- package/dist/book/paginate-types.d.ts +170 -0
- package/dist/book/types.d.ts +1 -1
- package/dist/book-css.d.ts +5 -0
- package/dist/book-css.js +14 -0
- package/dist/control-char.js +10 -0
- package/dist/css-tokens.js +189 -0
- package/dist/font-css.d.ts +11 -0
- package/dist/font-css.js +75 -0
- package/dist/font-format.d.ts +4 -0
- package/dist/font-format.js +11 -0
- package/dist/font-references.d.ts +4 -0
- package/dist/font-references.js +192 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.js +14 -2
- package/dist/motifs.d.ts +32 -0
- package/dist/motifs.js +245 -0
- package/dist/printed-pages.d.ts +42 -0
- package/dist/printed-pages.js +97 -0
- package/dist/sanitize-css.d.ts +6 -0
- package/dist/sanitize-css.js +308 -0
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +37 -15
- package/dist/unicode-range.d.ts +11 -0
- package/dist/unicode-range.js +86 -0
- package/package.json +2 -2
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { cssEscape, cssTokens } from "./css-tokens.js";
|
|
2
|
+
//#region src/sanitize-css.ts
|
|
3
|
+
const DATA_IMAGE = /^data:image\//i;
|
|
4
|
+
const DATA_FONT = /^data:(?:font\/[^;,]+|application\/(?:x-)?font-[^;,]+)(?:[;,])/i;
|
|
5
|
+
const DECLARATION_AT_RULES = /* @__PURE__ */ new Set([
|
|
6
|
+
"counter-style",
|
|
7
|
+
"font-face",
|
|
8
|
+
"font-palette-values",
|
|
9
|
+
"page",
|
|
10
|
+
"property"
|
|
11
|
+
]);
|
|
12
|
+
const FETCH_FUNCTIONS = [
|
|
13
|
+
"url",
|
|
14
|
+
"src",
|
|
15
|
+
"image-set",
|
|
16
|
+
"-webkit-image-set"
|
|
17
|
+
];
|
|
18
|
+
const FETCH_AT_KEYWORDS = ["import"];
|
|
19
|
+
const FETCH_CONSTRUCT = new RegExp(`@(?:${FETCH_AT_KEYWORDS.join("|")})|(?:${FETCH_FUNCTIONS.join("|")})\\(`, "gi");
|
|
20
|
+
const FETCH_FUNCTION_SET = new Set(FETCH_FUNCTIONS);
|
|
21
|
+
const FETCH_AT_KEYWORD_SET = new Set(FETCH_AT_KEYWORDS);
|
|
22
|
+
function normalizedUrl(value, decodeEscapes = true) {
|
|
23
|
+
let normalized = "";
|
|
24
|
+
for (let at = 0; at < value.length;) if (value.startsWith("/*", at)) {
|
|
25
|
+
const close = value.indexOf("*/", at + 2);
|
|
26
|
+
at = close < 0 ? value.length : close + 2;
|
|
27
|
+
} else if (decodeEscapes && value[at] === "\\") {
|
|
28
|
+
const escaped = cssEscape(value, at);
|
|
29
|
+
normalized += escaped[0];
|
|
30
|
+
at = escaped[1];
|
|
31
|
+
} else normalized += value[at++] ?? "";
|
|
32
|
+
normalized = normalized.trim();
|
|
33
|
+
if ((normalized[0] === "\"" || normalized[0] === "'") && normalized.at(-1) === normalized[0]) normalized = normalized.slice(1, -1);
|
|
34
|
+
return [...normalized].filter((character) => character.charCodeAt(0) > 32 && character.charCodeAt(0) !== 127).join("").toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
function decodedCss(source) {
|
|
37
|
+
let decoded = "";
|
|
38
|
+
for (let at = 0; at < source.length;) if (source[at] === "\\") {
|
|
39
|
+
const escaped = cssEscape(source, at);
|
|
40
|
+
decoded += escaped[0];
|
|
41
|
+
at = escaped[1];
|
|
42
|
+
} else decoded += source[at++] ?? "";
|
|
43
|
+
return decoded.replace(/\/\*[\s\S]*?(?:\*\/|$)/g, "").replace(/\s+/g, " ").toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
function functionArgument(value) {
|
|
46
|
+
let depth = 1;
|
|
47
|
+
let quote = "";
|
|
48
|
+
for (let at = 0; at < value.length; at++) {
|
|
49
|
+
const character = value[at] ?? "";
|
|
50
|
+
if (quote) {
|
|
51
|
+
if (character === quote) quote = "";
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (character === "\"" || character === "'") quote = character;
|
|
55
|
+
else if (character === "(") depth++;
|
|
56
|
+
else if (character === ")" && --depth === 0) return value.slice(0, at);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function imageSetItems(value) {
|
|
61
|
+
const items = [];
|
|
62
|
+
let start = 0;
|
|
63
|
+
let depth = 0;
|
|
64
|
+
let quote = "";
|
|
65
|
+
for (let at = 0; at < value.length; at++) {
|
|
66
|
+
const character = value[at] ?? "";
|
|
67
|
+
if (quote) {
|
|
68
|
+
if (character === quote) quote = "";
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (character === "\"" || character === "'") quote = character;
|
|
72
|
+
else if (character === "(") depth++;
|
|
73
|
+
else if (character === ")") depth--;
|
|
74
|
+
else if (character === "," && depth === 0) {
|
|
75
|
+
items.push(value.slice(start, at));
|
|
76
|
+
start = at + 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
items.push(value.slice(start));
|
|
80
|
+
return items;
|
|
81
|
+
}
|
|
82
|
+
function allowedAuditData(value, dataFonts) {
|
|
83
|
+
let argument = value.trimStart();
|
|
84
|
+
if (/^url\(/i.test(argument)) argument = functionArgument(argument.slice(argument.indexOf("(") + 1)).trimStart();
|
|
85
|
+
argument = argument.replace(/^["']/, "");
|
|
86
|
+
return DATA_IMAGE.test(argument) || dataFonts && DATA_FONT.test(argument);
|
|
87
|
+
}
|
|
88
|
+
function lexicalFindings(source, dataFonts) {
|
|
89
|
+
const text = decodedCss(source);
|
|
90
|
+
const found = [];
|
|
91
|
+
for (const match of text.matchAll(FETCH_CONSTRUCT)) {
|
|
92
|
+
const construct = match[0].toLowerCase();
|
|
93
|
+
const after = text.slice((match.index ?? 0) + construct.length);
|
|
94
|
+
if (construct.startsWith("@")) {
|
|
95
|
+
if (!allowedAuditData(after, dataFonts)) found.push(construct);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const name = construct.slice(0, -1);
|
|
99
|
+
const argument = functionArgument(after);
|
|
100
|
+
if (!(name === "image-set" || name === "-webkit-image-set" ? imageSetItems(argument).every((item) => allowedAuditData(item, dataFonts)) : allowedAuditData(argument, dataFonts))) found.push(`${name === "-webkit-image-set" ? "image-set" : name}()`);
|
|
101
|
+
}
|
|
102
|
+
return [...new Set(found)];
|
|
103
|
+
}
|
|
104
|
+
function matchingClose(list, open, limit = list.length) {
|
|
105
|
+
let depth = 1;
|
|
106
|
+
for (let at = open + 1; at < limit; at++) {
|
|
107
|
+
if (list[at]?.kind === "open" || list[at]?.kind === "function") depth++;
|
|
108
|
+
if (list[at]?.kind === "close" && --depth === 0) return at;
|
|
109
|
+
}
|
|
110
|
+
return limit;
|
|
111
|
+
}
|
|
112
|
+
function unsafeImageSet(list, start, close) {
|
|
113
|
+
let depth = 0;
|
|
114
|
+
for (let at = start + 1; at < close; at++) {
|
|
115
|
+
const token = list[at];
|
|
116
|
+
if (token?.kind === "function" || token?.kind === "open") depth++;
|
|
117
|
+
else if (token?.kind === "close") depth--;
|
|
118
|
+
else if (token?.kind === "string" && depth === 0 && !DATA_IMAGE.test(normalizedUrl(token.value, false))) return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
function urlFinding(value, dataFonts, fontFace, finding = "url()") {
|
|
123
|
+
if (DATA_IMAGE.test(value)) return void 0;
|
|
124
|
+
if (fontFace && DATA_FONT.test(value)) return dataFonts ? void 0 : "data font";
|
|
125
|
+
return finding;
|
|
126
|
+
}
|
|
127
|
+
function functionFinding(source, list, at, end, dataFonts, fontFace) {
|
|
128
|
+
const token = list[at];
|
|
129
|
+
if (token?.kind === "bad-url") return "url()";
|
|
130
|
+
if (token?.kind === "url") return urlFinding(token.value, dataFonts, fontFace);
|
|
131
|
+
if (token?.kind !== "function") return void 0;
|
|
132
|
+
const close = matchingClose(list, at, end);
|
|
133
|
+
if (!FETCH_FUNCTION_SET.has(token.value)) return void 0;
|
|
134
|
+
if (token.value === "image-set" || token.value === "-webkit-image-set") return unsafeImageSet(list, at, close) ? "image-set()" : void 0;
|
|
135
|
+
return urlFinding(normalizedUrl(source.slice(token.end, close < end ? list[close]?.start : source.length)), dataFonts, fontFace, `${token.value}()`);
|
|
136
|
+
}
|
|
137
|
+
function findings(source, list, start, end, dataFonts, fontFace) {
|
|
138
|
+
const found = [];
|
|
139
|
+
for (let at = start; at < end; at++) {
|
|
140
|
+
const token = list[at];
|
|
141
|
+
if (token?.kind === "at" && FETCH_AT_KEYWORD_SET.has(token.value)) found.push(`@${token.value}`);
|
|
142
|
+
const finding = functionFinding(source, list, at, end, dataFonts, fontFace);
|
|
143
|
+
if (finding) found.push(finding);
|
|
144
|
+
}
|
|
145
|
+
return [...new Set(found)];
|
|
146
|
+
}
|
|
147
|
+
function boundary(list, start, end) {
|
|
148
|
+
let depth = 0;
|
|
149
|
+
for (let at = start; at < end; at++) {
|
|
150
|
+
const token = list[at];
|
|
151
|
+
if (!token) continue;
|
|
152
|
+
if (depth === 0 && (token.kind === "semicolon" || token.kind === "open" && token.value === "{")) return at;
|
|
153
|
+
if (token.kind === "function" || token.kind === "open") depth++;
|
|
154
|
+
else if (token.kind === "close") depth--;
|
|
155
|
+
}
|
|
156
|
+
return end;
|
|
157
|
+
}
|
|
158
|
+
function cleanStatement(source, list, start, edge, dataFonts, removed) {
|
|
159
|
+
const first = list[start];
|
|
160
|
+
const delimiter = list[edge];
|
|
161
|
+
if (!first || !delimiter) return {
|
|
162
|
+
character: source.length,
|
|
163
|
+
next: edge,
|
|
164
|
+
text: ""
|
|
165
|
+
};
|
|
166
|
+
const unsafe = first.kind === "at" && first.value === "import" ? ["@import"] : findings(source, list, start, edge + 1, dataFonts, false);
|
|
167
|
+
if (unsafe.length > 0) removed.push(...unsafe);
|
|
168
|
+
return {
|
|
169
|
+
character: delimiter.end,
|
|
170
|
+
next: edge + 1,
|
|
171
|
+
text: unsafe.length === 0 ? source.slice(first.start, delimiter.end) : ""
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function cleanBlockRule(source, list, start, edge, end, dataFonts, removed) {
|
|
175
|
+
const first = list[start];
|
|
176
|
+
const delimiter = list[edge];
|
|
177
|
+
if (!first || !delimiter) return {
|
|
178
|
+
character: source.length,
|
|
179
|
+
next: end,
|
|
180
|
+
text: ""
|
|
181
|
+
};
|
|
182
|
+
const close = matchingClose(list, edge, end);
|
|
183
|
+
const ruleName = first.kind === "at" ? first.value : "";
|
|
184
|
+
const fontFace = ruleName === "font-face";
|
|
185
|
+
const unsafe = findings(source, list, start, edge, dataFonts, false);
|
|
186
|
+
if (fontFace) unsafe.push(...findings(source, list, edge + 1, close, dataFonts, true));
|
|
187
|
+
if (unsafe.length > 0) removed.push(...unsafe);
|
|
188
|
+
let text = "";
|
|
189
|
+
if (unsafe.length === 0) {
|
|
190
|
+
const body = first.kind !== "at" || DECLARATION_AT_RULES.has(ruleName) ? cleanDeclarations(source, list, edge + 1, close, dataFonts, removed, fontFace) : cleanRules(source, list, edge + 1, close, dataFonts, removed);
|
|
191
|
+
text = source.slice(first.start, delimiter.end) + body;
|
|
192
|
+
if (close < end) text += source.slice(list[close]?.start, list[close]?.end);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
character: close < end ? list[close]?.end ?? delimiter.end : source.length,
|
|
196
|
+
next: close < end ? close + 1 : end,
|
|
197
|
+
text
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function cleanRuleUnit(source, list, start, end, dataFonts, removed) {
|
|
201
|
+
const first = list[start];
|
|
202
|
+
if (!first) return {
|
|
203
|
+
character: source.length,
|
|
204
|
+
next: end,
|
|
205
|
+
text: ""
|
|
206
|
+
};
|
|
207
|
+
const edge = boundary(list, start, end);
|
|
208
|
+
const delimiter = list[edge];
|
|
209
|
+
if (edge >= end && delimiter?.kind === "close") {
|
|
210
|
+
const unsafe = findings(source, list, start, end, dataFonts, false);
|
|
211
|
+
if (unsafe.length > 0) removed.push(...unsafe);
|
|
212
|
+
return {
|
|
213
|
+
character: delimiter.start,
|
|
214
|
+
next: end,
|
|
215
|
+
text: unsafe.length === 0 ? source.slice(first.start, delimiter.start) : ""
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (!delimiter) {
|
|
219
|
+
const unsafe = findings(source, list, start, end, dataFonts, false);
|
|
220
|
+
if (unsafe.length > 0) removed.push(...unsafe);
|
|
221
|
+
return {
|
|
222
|
+
character: source.length,
|
|
223
|
+
next: end,
|
|
224
|
+
text: unsafe.length === 0 ? source.slice(first.start) : ""
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return delimiter.kind === "semicolon" ? cleanStatement(source, list, start, edge, dataFonts, removed) : cleanBlockRule(source, list, start, edge, end, dataFonts, removed);
|
|
228
|
+
}
|
|
229
|
+
function cleanRules(source, list, start, end, dataFonts, removed) {
|
|
230
|
+
let output = "";
|
|
231
|
+
let character = start === 0 ? 0 : list[start - 1]?.end ?? source.length;
|
|
232
|
+
let at = start;
|
|
233
|
+
while (at < end) {
|
|
234
|
+
const first = list[at];
|
|
235
|
+
if (!first) break;
|
|
236
|
+
output += source.slice(character, first.start);
|
|
237
|
+
const unit = cleanRuleUnit(source, list, at, end, dataFonts, removed);
|
|
238
|
+
output += unit.text;
|
|
239
|
+
character = unit.character;
|
|
240
|
+
at = unit.next;
|
|
241
|
+
}
|
|
242
|
+
const rangeEnd = end < list.length ? list[end]?.start ?? source.length : source.length;
|
|
243
|
+
return output + source.slice(character, rangeEnd);
|
|
244
|
+
}
|
|
245
|
+
function cleanDeclarationSegment(source, list, start, end, character, dataFonts, fontFace, removed) {
|
|
246
|
+
const delimiter = list[end];
|
|
247
|
+
const hasSemicolon = delimiter?.kind === "semicolon";
|
|
248
|
+
const segmentEnd = hasSemicolon ? end + 1 : end;
|
|
249
|
+
const finish = hasSemicolon ? delimiter.end : end < list.length ? delimiter?.start ?? source.length : source.length;
|
|
250
|
+
const unsafe = findings(source, list, start, segmentEnd, dataFonts, fontFace);
|
|
251
|
+
if (unsafe.length === 0) return [source.slice(character, finish), finish];
|
|
252
|
+
removed.push(...unsafe);
|
|
253
|
+
return [source.slice(character, list[start]?.start ?? finish), finish];
|
|
254
|
+
}
|
|
255
|
+
function declarationEnd(list, start, end) {
|
|
256
|
+
let depth = 0;
|
|
257
|
+
for (let at = start; at < end; at++) {
|
|
258
|
+
const token = list[at];
|
|
259
|
+
if (depth === 0 && token?.kind === "semicolon") return at;
|
|
260
|
+
if (token?.kind === "function" || token?.kind === "open") depth++;
|
|
261
|
+
else if (token?.kind === "close") depth--;
|
|
262
|
+
}
|
|
263
|
+
return end;
|
|
264
|
+
}
|
|
265
|
+
function isDeclaration(list, start) {
|
|
266
|
+
return list[start]?.value.startsWith("--") === true && list[start + 1]?.value === ":";
|
|
267
|
+
}
|
|
268
|
+
function cleanDeclarations(source, list, start, end, dataFonts, removed, fontFace = false) {
|
|
269
|
+
let output = "";
|
|
270
|
+
let character = start === 0 ? 0 : list[start - 1]?.end ?? source.length;
|
|
271
|
+
let at = start;
|
|
272
|
+
while (at < end) {
|
|
273
|
+
const first = list[at];
|
|
274
|
+
if (!first) break;
|
|
275
|
+
output += source.slice(character, first.start);
|
|
276
|
+
const edge = boundary(list, at, end);
|
|
277
|
+
if (list[edge]?.kind === "open" && !isDeclaration(list, at)) {
|
|
278
|
+
const unit = cleanBlockRule(source, list, at, edge, end, dataFonts, removed);
|
|
279
|
+
output += unit.text;
|
|
280
|
+
character = unit.character;
|
|
281
|
+
at = unit.next;
|
|
282
|
+
} else {
|
|
283
|
+
const finish = declarationEnd(list, at, end);
|
|
284
|
+
const cleaned = cleanDeclarationSegment(source, list, at, finish, first.start, dataFonts, fontFace, removed);
|
|
285
|
+
output += cleaned[0];
|
|
286
|
+
character = cleaned[1];
|
|
287
|
+
at = finish < end ? finish + 1 : end;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const rangeEnd = end < list.length ? list[end]?.start ?? source.length : source.length;
|
|
291
|
+
return output + source.slice(character, rangeEnd);
|
|
292
|
+
}
|
|
293
|
+
function sanitizeCss(source, declarations, dataFonts) {
|
|
294
|
+
const list = cssTokens(source);
|
|
295
|
+
const removed = [];
|
|
296
|
+
const css = declarations ? cleanDeclarations(source, list, 0, list.length, dataFonts, removed) : cleanRules(source, list, 0, list.length, dataFonts, removed);
|
|
297
|
+
const auditRemoved = lexicalFindings(css, dataFonts);
|
|
298
|
+
if (auditRemoved.length > 0) return {
|
|
299
|
+
css: "",
|
|
300
|
+
removed: [.../* @__PURE__ */ new Set([...removed, ...auditRemoved])]
|
|
301
|
+
};
|
|
302
|
+
return {
|
|
303
|
+
css,
|
|
304
|
+
removed: [...new Set(removed)]
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
308
|
+
export { lexicalFindings, sanitizeCss };
|
package/dist/sanitize.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare const REFUSED_ELEMENTS: Set<string>;
|
|
|
4
4
|
export interface SanitizeOptions {
|
|
5
5
|
linkOrigins?: Iterable<string>;
|
|
6
6
|
keepScripts?: boolean;
|
|
7
|
+
dataFonts?: boolean;
|
|
7
8
|
refuseElements?: Iterable<string>;
|
|
8
9
|
}
|
|
9
10
|
export declare function findActiveContent(html: string, options?: SanitizeOptions): string[];
|
package/dist/sanitize.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeCss } from "./sanitize-css.js";
|
|
1
2
|
import { isTag } from "domhandler";
|
|
2
3
|
import render from "dom-serializer";
|
|
3
4
|
import { parseDocument } from "htmlparser2";
|
|
@@ -44,7 +45,9 @@ const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
|
44
45
|
"longdesc",
|
|
45
46
|
"ping"
|
|
46
47
|
]);
|
|
47
|
-
const
|
|
48
|
+
const DATA_IMAGE_TYPES = String.raw`(?:png|jpeg|gif|webp|svg\+xml)`;
|
|
49
|
+
const DATA_IMAGE = new RegExp(`^data:image/${DATA_IMAGE_TYPES}(?:[;,])`, "i");
|
|
50
|
+
const DATA_IMAGE_SRCSET = new RegExp(`^data:image/${DATA_IMAGE_TYPES}(?:;[^,]*)?,[^,]*$`, "i");
|
|
48
51
|
function parse(html) {
|
|
49
52
|
return parseDocument(html, {
|
|
50
53
|
lowerCaseAttributeNames: true,
|
|
@@ -94,24 +97,13 @@ function safeUrl(attribute, value, allowed) {
|
|
|
94
97
|
return false;
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
|
-
if (attribute === "srcset") return
|
|
100
|
+
if (attribute === "srcset") return DATA_IMAGE_SRCSET.test(url);
|
|
98
101
|
if (attribute === "src" || attribute === "poster") return DATA_IMAGE.test(url);
|
|
99
102
|
return false;
|
|
100
103
|
}
|
|
101
|
-
function safeStyle(value) {
|
|
102
|
-
const matches = [...value.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gi)];
|
|
103
|
-
if ((value.match(/url\(/gi)?.length ?? 0) !== matches.length) return false;
|
|
104
|
-
return matches.every((match) => normalizeUrl(match[2] ?? "").startsWith("data:image/"));
|
|
105
|
-
}
|
|
106
|
-
function refusedStyleElement(element) {
|
|
107
|
-
if (element.name.toLowerCase() !== "style") return false;
|
|
108
|
-
const text = element.children.map((child) => "data" in child ? child.data : "").join("");
|
|
109
|
-
return /@import/i.test(text) || !safeStyle(text);
|
|
110
|
-
}
|
|
111
104
|
function refusedElement(element, refused, keepScripts) {
|
|
112
105
|
const name = element.name.toLowerCase();
|
|
113
106
|
if (name === "script") return !keepScripts;
|
|
114
|
-
if (refusedStyleElement(element)) return true;
|
|
115
107
|
if (refused.has(name)) return true;
|
|
116
108
|
if (insideSvg(element) && REFUSED_SVG_ELEMENTS.has(name)) return true;
|
|
117
109
|
return name === "meta" && Object.keys(element.attribs).some((attribute) => attribute.toLowerCase() === "http-equiv");
|
|
@@ -119,11 +111,23 @@ function refusedElement(element, refused, keepScripts) {
|
|
|
119
111
|
function refusedAttribute(name, value, allowed, keepScripts) {
|
|
120
112
|
const attribute = name.toLowerCase();
|
|
121
113
|
if (attribute.startsWith("on")) return !keepScripts;
|
|
122
|
-
return REFUSED_ATTRIBUTES.has(attribute) || URL_ATTRIBUTES.has(attribute) && !safeUrl(attribute, value, allowed)
|
|
114
|
+
return REFUSED_ATTRIBUTES.has(attribute) || URL_ATTRIBUTES.has(attribute) && !safeUrl(attribute, value, allowed);
|
|
123
115
|
}
|
|
124
116
|
function refusedSet(options) {
|
|
125
117
|
return new Set([...REFUSED_ELEMENTS, ...options.refuseElements ?? []].map((name) => name.toLowerCase()));
|
|
126
118
|
}
|
|
119
|
+
function styleText(element) {
|
|
120
|
+
return element.children.map((child) => "data" in child ? child.data : "").join("");
|
|
121
|
+
}
|
|
122
|
+
function styleFindings(element, dataFonts) {
|
|
123
|
+
const found = [];
|
|
124
|
+
if (element.name.toLowerCase() === "style") for (const construct of sanitizeCss(styleText(element), false, dataFonts).removed) found.push(`<style> ${construct}`);
|
|
125
|
+
for (const [name, value] of Object.entries(element.attribs)) {
|
|
126
|
+
if (name.toLowerCase() !== "style") continue;
|
|
127
|
+
for (const construct of sanitizeCss(value, true, dataFonts).removed) found.push(`${element.name}[${name}] ${construct}`);
|
|
128
|
+
}
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
127
131
|
function findActiveContent(html, options = {}) {
|
|
128
132
|
const allowed = origins(options.linkOrigins);
|
|
129
133
|
const refused = refusedSet(options);
|
|
@@ -131,10 +135,27 @@ function findActiveContent(html, options = {}) {
|
|
|
131
135
|
const found = [];
|
|
132
136
|
for (const element of elements(parse(html).children)) {
|
|
133
137
|
if (refusedElement(element, refused, keepScripts)) found.push(`<${element.name}>`);
|
|
138
|
+
found.push(...styleFindings(element, options.dataFonts ?? false));
|
|
134
139
|
for (const [name, value] of Object.entries(element.attribs)) if (refusedAttribute(name, value, allowed, keepScripts)) found.push(`${element.name}[${name}]`);
|
|
135
140
|
}
|
|
136
141
|
return found;
|
|
137
142
|
}
|
|
143
|
+
function cleanStyleElement(element, dataFonts) {
|
|
144
|
+
if (element.name.toLowerCase() !== "style") return;
|
|
145
|
+
const children = element.children.filter((candidate) => "data" in candidate);
|
|
146
|
+
const child = children[0];
|
|
147
|
+
if (!child || !("data" in child)) return;
|
|
148
|
+
child.data = sanitizeCss(styleText(element), false, dataFonts).css;
|
|
149
|
+
for (const extra of children.slice(1)) if ("data" in extra) extra.data = "";
|
|
150
|
+
}
|
|
151
|
+
function cleanAttributes(element, allowed, keepScripts, dataFonts) {
|
|
152
|
+
for (const [name, value] of Object.entries(element.attribs)) if (refusedAttribute(name, value, allowed, keepScripts)) delete element.attribs[name];
|
|
153
|
+
else if (name.toLowerCase() === "style") {
|
|
154
|
+
const css = sanitizeCss(value, true, dataFonts).css.trim();
|
|
155
|
+
if (css) element.attribs[name] = css;
|
|
156
|
+
else delete element.attribs[name];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
138
159
|
function sanitize(html, options = {}) {
|
|
139
160
|
const allowed = origins(options.linkOrigins);
|
|
140
161
|
const refused = refusedSet(options);
|
|
@@ -143,7 +164,8 @@ function sanitize(html, options = {}) {
|
|
|
143
164
|
const prune = (nodes) => nodes.filter((node) => {
|
|
144
165
|
if (!isTag(node)) return true;
|
|
145
166
|
if (refusedElement(node, refused, keepScripts)) return false;
|
|
146
|
-
|
|
167
|
+
cleanStyleElement(node, options.dataFonts ?? false);
|
|
168
|
+
cleanAttributes(node, allowed, keepScripts, options.dataFonts ?? false);
|
|
147
169
|
node.children = prune(node.children);
|
|
148
170
|
return true;
|
|
149
171
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/unicode-range.d.ts
|
|
2
|
+
export type UnicodeRangeParse = {
|
|
3
|
+
ok: true;
|
|
4
|
+
value: string;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
reason: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const UNICODE_RANGE_CLEARANCE: string;
|
|
10
|
+
export declare function parseUnicodeRange(range: string): UnicodeRangeParse;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//#region src/unicode-range.ts
|
|
2
|
+
const MAX_CODE_POINT = 1114111;
|
|
3
|
+
const MAX_CODE_POINT_HEX = MAX_CODE_POINT.toString(16).toUpperCase();
|
|
4
|
+
const DESCRIPTOR_FORM = "U+hex, U+hex-hex, or U+hex with ? wildcards";
|
|
5
|
+
const UNICODE_RANGE_CLEARANCE = `${DESCRIPTOR_FORM}; code points at most ${MAX_CODE_POINT_HEX}`;
|
|
6
|
+
function codePoint(hex) {
|
|
7
|
+
return Number.parseInt(hex, 16);
|
|
8
|
+
}
|
|
9
|
+
function aboveMax(hex, token) {
|
|
10
|
+
if (codePoint(hex) > MAX_CODE_POINT) return {
|
|
11
|
+
ok: false,
|
|
12
|
+
reason: `${token} has a code point above ${MAX_CODE_POINT_HEX}`
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function malformed(token) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
reason: `${token} is not a CSS unicode-range descriptor (${DESCRIPTOR_FORM})`
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function parseRange(token) {
|
|
22
|
+
const match = /^[Uu]\+([0-9A-Fa-f]{1,6})-([0-9A-Fa-f]{1,6})$/.exec(token);
|
|
23
|
+
if (!match) return;
|
|
24
|
+
const start = match[1] ?? "";
|
|
25
|
+
const end = match[2] ?? "";
|
|
26
|
+
const high = aboveMax(start, token) ?? aboveMax(end, token);
|
|
27
|
+
if (high) return high;
|
|
28
|
+
if (codePoint(start) > codePoint(end)) return {
|
|
29
|
+
ok: false,
|
|
30
|
+
reason: `${token} has start greater than end`
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
value: `U+${start.toUpperCase()}-${end.toUpperCase()}`
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function parseWildcard(token) {
|
|
38
|
+
const match = /^[Uu]\+([0-9A-Fa-f]{0,5})(\?+)$/.exec(token);
|
|
39
|
+
if (!match) return;
|
|
40
|
+
const hex = match[1] ?? "";
|
|
41
|
+
const marks = match[2] ?? "";
|
|
42
|
+
if (hex.length + marks.length > 6) return malformed(token);
|
|
43
|
+
const high = aboveMax(`${hex}${"0".repeat(marks.length)}`, token) ?? aboveMax(`${hex}${"F".repeat(marks.length)}`, token);
|
|
44
|
+
if (high) return high;
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
value: `U+${hex.toUpperCase()}${marks}`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function parseSingle(token) {
|
|
51
|
+
const match = /^[Uu]\+([0-9A-Fa-f]{1,6})$/.exec(token);
|
|
52
|
+
if (!match) return;
|
|
53
|
+
const hex = match[1] ?? "";
|
|
54
|
+
const high = aboveMax(hex, token);
|
|
55
|
+
if (high) return high;
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
value: `U+${hex.toUpperCase()}`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function parseDescriptor(token) {
|
|
62
|
+
return parseRange(token) ?? parseWildcard(token) ?? parseSingle(token) ?? malformed(token);
|
|
63
|
+
}
|
|
64
|
+
function parseUnicodeRange(range) {
|
|
65
|
+
if (range.trim() === "") return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: "the list is empty"
|
|
68
|
+
};
|
|
69
|
+
const tokens = range.split(",").map((part) => part.trim());
|
|
70
|
+
if (tokens.some((token) => token.length === 0)) return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: "the list has an empty entry"
|
|
73
|
+
};
|
|
74
|
+
const parsed = [];
|
|
75
|
+
for (const token of tokens) {
|
|
76
|
+
const result = parseDescriptor(token);
|
|
77
|
+
if (!result.ok) return result;
|
|
78
|
+
parsed.push(result.value);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
value: parsed.join(", ")
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
export { UNICODE_RANGE_CLEARANCE, parseUnicodeRange };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segnavia/html",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@segnavia/format": "0.
|
|
29
|
+
"@segnavia/format": "0.28.0",
|
|
30
30
|
"dom-serializer": "^2.0.0",
|
|
31
31
|
"domhandler": "^5.0.3",
|
|
32
32
|
"htmlparser2": "^10.0.0",
|