@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.
Files changed (50) hide show
  1. package/dist/base64.d.ts +4 -0
  2. package/dist/base64.js +51 -0
  3. package/dist/bindings.d.ts +4 -1
  4. package/dist/bindings.js +19 -2
  5. package/dist/book/book-document.js +146 -65
  6. package/dist/book/book-held.js +182 -0
  7. package/dist/book/book-view.d.ts +5 -0
  8. package/dist/book/book-view.js +26 -0
  9. package/dist/book/furniture.d.ts +14 -0
  10. package/dist/book/furniture.js +36 -0
  11. package/dist/book/notes.js +18 -9
  12. package/dist/book/paginate-book.d.ts +11 -0
  13. package/dist/book/paginate-book.js +195 -0
  14. package/dist/book/paginate-columns.d.ts +8 -0
  15. package/dist/book/paginate-columns.js +119 -0
  16. package/dist/book/paginate-keeps.d.ts +4 -0
  17. package/dist/book/paginate-keeps.js +80 -0
  18. package/dist/book/paginate-marks.d.ts +7 -0
  19. package/dist/book/paginate-marks.js +34 -0
  20. package/dist/book/paginate-notes.d.ts +21 -0
  21. package/dist/book/paginate-notes.js +105 -0
  22. package/dist/book/paginate-sequence.d.ts +4 -0
  23. package/dist/book/paginate-sequence.js +267 -0
  24. package/dist/book/paginate-target.d.ts +13 -0
  25. package/dist/book/paginate-target.js +36 -0
  26. package/dist/book/paginate-types.d.ts +170 -0
  27. package/dist/book/types.d.ts +1 -1
  28. package/dist/book-css.d.ts +5 -0
  29. package/dist/book-css.js +14 -0
  30. package/dist/control-char.js +10 -0
  31. package/dist/css-tokens.js +189 -0
  32. package/dist/font-css.d.ts +11 -0
  33. package/dist/font-css.js +75 -0
  34. package/dist/font-format.d.ts +4 -0
  35. package/dist/font-format.js +11 -0
  36. package/dist/font-references.d.ts +4 -0
  37. package/dist/font-references.js +192 -0
  38. package/dist/index.d.ts +15 -2
  39. package/dist/index.js +14 -2
  40. package/dist/motifs.d.ts +32 -0
  41. package/dist/motifs.js +245 -0
  42. package/dist/printed-pages.d.ts +42 -0
  43. package/dist/printed-pages.js +97 -0
  44. package/dist/sanitize-css.d.ts +6 -0
  45. package/dist/sanitize-css.js +308 -0
  46. package/dist/sanitize.d.ts +1 -0
  47. package/dist/sanitize.js +37 -15
  48. package/dist/unicode-range.d.ts +11 -0
  49. package/dist/unicode-range.js +86 -0
  50. 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 };
@@ -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 DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp|svg\+xml)(?:[;,])/i;
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 /^data:image\/(?:png|jpeg|gif|webp|svg\+xml)(?:;[^,]*)?,[^,]*$/i.test(url);
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) || attribute === "style" && !safeStyle(value);
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
- for (const [name, value] of Object.entries(node.attribs)) if (refusedAttribute(name, value, allowed, keepScripts)) delete node.attribs[name];
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.26.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.26.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",