@segnavia/html 0.20.0 → 0.22.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/bindings.d.ts +1 -0
- package/dist/bindings.js +3 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/sanitize.js +3 -1
- package/dist/slots.d.ts +19 -0
- package/dist/slots.js +237 -0
- package/package.json +2 -2
package/dist/bindings.d.ts
CHANGED
package/dist/bindings.js
CHANGED
|
@@ -96,6 +96,7 @@ function fill(html, options) {
|
|
|
96
96
|
const path = take(element, names.value);
|
|
97
97
|
if (path === void 0) return;
|
|
98
98
|
const value = resolve(path, stack);
|
|
99
|
+
if (value === void 0 && options.preserveUnavailable) return;
|
|
99
100
|
element.children = [];
|
|
100
101
|
if (typeof value === "string" || typeof value === "number" && Number.isFinite(value)) {
|
|
101
102
|
const text = new Text(String(value));
|
|
@@ -106,10 +107,12 @@ function fill(html, options) {
|
|
|
106
107
|
} else if (value !== void 0) unavailable.add(path);
|
|
107
108
|
};
|
|
108
109
|
const applyUrls = (element, stack) => {
|
|
110
|
+
const preserves = (value) => value === void 0 && options.preserveUnavailable;
|
|
109
111
|
for (const attribute of ["href", "src"]) {
|
|
110
112
|
const path = take(element, names[attribute]);
|
|
111
113
|
if (path === void 0) continue;
|
|
112
114
|
const value = resolve(path, stack);
|
|
115
|
+
if (preserves(value)) continue;
|
|
113
116
|
if (typeof value === "string" && (attribute === "href" ? safeHref(value, origins) : /^data:image\//i.test(value))) element.attribs[attribute] = value;
|
|
114
117
|
else {
|
|
115
118
|
delete element.attribs[attribute];
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { REFUSED_ELEMENTS, SanitizeOptions, findActiveContent, sanitize } from "
|
|
|
2
2
|
import { BINDINGS, FillOptions, Filled, bindingNames, fill, hasBindings } from "./bindings.js";
|
|
3
3
|
import { BookDocument, BookDocumentOptions, BookHeading, BookPrintPart, BookSectionInfo, bookPrintPart } from "./book/types.js";
|
|
4
4
|
import { bookDocument } from "./book/book-document.js";
|
|
5
|
+
import { MAX_SLOT_DECLARATIONS, PreparedSlots, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal } from "./slots.js";
|
|
5
6
|
//#region src/index.d.ts
|
|
6
7
|
export type HtmlKind = 'plain' | 'live-data' | 'scripted' | 'refused';
|
|
7
8
|
export declare function classify(html: string, prefix: string): HtmlKind;
|
|
8
9
|
export declare function searchText(html: string): string;
|
|
9
10
|
//#endregion
|
|
10
|
-
export { BINDINGS, type BookDocument, type BookDocumentOptions, type BookHeading, type BookPrintPart, type BookSectionInfo, type FillOptions, type Filled, REFUSED_ELEMENTS, type SanitizeOptions, bindingNames, bookDocument, bookPrintPart, fill, findActiveContent, hasBindings, sanitize };
|
|
11
|
+
export { BINDINGS, type BookDocument, type BookDocumentOptions, type BookHeading, type BookPrintPart, type BookSectionInfo, type FillOptions, type Filled, MAX_SLOT_DECLARATIONS, type PreparedSlots, REFUSED_ELEMENTS, type SanitizeOptions, SlotFillRefusedError, bindingNames, bookDocument, bookPrintPart, fill, findActiveContent, hasBindings, hasSlotWork, prepareSlotCollection, prepareSlots, sanitize, slotFillRefusal };
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { REFUSED_ELEMENTS, elements, findActiveContent, parse, sanitize } from "
|
|
|
2
2
|
import { BINDINGS, bindingNames, fill, hasBindings } from "./bindings.js";
|
|
3
3
|
import { bookPrintPart } from "./book/types.js";
|
|
4
4
|
import { bookDocument } from "./book/book-document.js";
|
|
5
|
+
import { MAX_SLOT_DECLARATIONS, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal } from "./slots.js";
|
|
5
6
|
//#region src/index.ts
|
|
6
7
|
function classify(html, prefix) {
|
|
7
8
|
const scripted = elements(parse(html).children).some((element) => element.name.toLowerCase() === "script");
|
|
@@ -14,4 +15,4 @@ function searchText(html) {
|
|
|
14
15
|
return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
|
15
16
|
}
|
|
16
17
|
//#endregion
|
|
17
|
-
export { BINDINGS, REFUSED_ELEMENTS, bindingNames, bookDocument, bookPrintPart, classify, fill, findActiveContent, hasBindings, sanitize, searchText };
|
|
18
|
+
export { BINDINGS, MAX_SLOT_DECLARATIONS, REFUSED_ELEMENTS, SlotFillRefusedError, bindingNames, bookDocument, bookPrintPart, classify, fill, findActiveContent, hasBindings, hasSlotWork, prepareSlotCollection, prepareSlots, sanitize, searchText, slotFillRefusal };
|
package/dist/sanitize.js
CHANGED
|
@@ -48,7 +48,9 @@ const DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp|svg\+xml)(?:[;,])/i;
|
|
|
48
48
|
function parse(html) {
|
|
49
49
|
return parseDocument(html, {
|
|
50
50
|
lowerCaseAttributeNames: true,
|
|
51
|
-
recognizeSelfClosing: true
|
|
51
|
+
recognizeSelfClosing: true,
|
|
52
|
+
withStartIndices: true,
|
|
53
|
+
withEndIndices: true
|
|
52
54
|
});
|
|
53
55
|
}
|
|
54
56
|
function serialize(node) {
|
package/dist/slots.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Scripts, SlotDeclaration, SlotReport, SlotsOptions } from "@segnavia/format";
|
|
2
|
+
//#region src/slots.d.ts
|
|
3
|
+
export declare const MAX_SLOT_DECLARATIONS = 200;
|
|
4
|
+
export interface PreparedSlots {
|
|
5
|
+
report: SlotReport;
|
|
6
|
+
apply(html: string): string;
|
|
7
|
+
}
|
|
8
|
+
export declare class SlotFillRefusedError extends Error {
|
|
9
|
+
constructor(scripts: Exclude<Scripts, 'refuse'>);
|
|
10
|
+
}
|
|
11
|
+
/** Whether an item needs slot preparation for declarations, filling, asset resolution or reporting. */
|
|
12
|
+
export declare function hasSlotWork(declarations: readonly SlotDeclaration[] | undefined, options: SlotsOptions | undefined): boolean;
|
|
13
|
+
/** Refuses exposing filled values to an item's own scripts. */
|
|
14
|
+
export declare function slotFillRefusal(scripts: Scripts, declarations: readonly SlotDeclaration[] | undefined, options: SlotsOptions | undefined): SlotFillRefusedError | undefined;
|
|
15
|
+
/** Prepares and applies the slots declared by one item. */
|
|
16
|
+
export declare function prepareSlots(declarations: readonly SlotDeclaration[] | undefined, options?: SlotsOptions, signal?: AbortSignal): Promise<PreparedSlots>;
|
|
17
|
+
/** Prepares one shared fill for a collection, merging each item's declarations exactly once. */
|
|
18
|
+
export declare function prepareSlotCollection(groups: ReadonlyArray<readonly SlotDeclaration[] | undefined>, options?: SlotsOptions, signal?: AbortSignal): Promise<PreparedSlots>;
|
|
19
|
+
//#endregion
|
package/dist/slots.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { elements, parse, serialize } from "./sanitize.js";
|
|
2
|
+
import { bindingNames, hasBindings } from "./bindings.js";
|
|
3
|
+
import { Text, isTag } from "domhandler";
|
|
4
|
+
import { SlotsInput, mergeSlotDeclarations } from "@segnavia/format";
|
|
5
|
+
//#region src/slots.ts
|
|
6
|
+
const MAX_SLOT_DECLARATIONS = 200;
|
|
7
|
+
const ASSET_RESOLUTION_CONCURRENCY = 4;
|
|
8
|
+
var SlotFillRefusedError = class extends Error {
|
|
9
|
+
constructor(scripts) {
|
|
10
|
+
super(`A fill was not applied because this item runs its own scripts (scripts: '${scripts}'), which could read the filled values. Set the item's scripts to 'refuse', or show it without a fill.`);
|
|
11
|
+
this.name = "SlotFillRefusedError";
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/** Whether an item needs slot preparation for declarations, filling, asset resolution or reporting. */
|
|
15
|
+
function hasSlotWork(declarations, options) {
|
|
16
|
+
return Boolean(declarations?.length || options?.declarations?.length || options?.fill || options?.resolveAsset || options?.report);
|
|
17
|
+
}
|
|
18
|
+
/** Refuses exposing filled values to an item's own scripts. */
|
|
19
|
+
function slotFillRefusal(scripts, declarations, options) {
|
|
20
|
+
const fillsValues = options?.fill && (declarations?.length || options.declarations?.length);
|
|
21
|
+
return scripts !== "refuse" && fillsValues ? new SlotFillRefusedError(scripts) : void 0;
|
|
22
|
+
}
|
|
23
|
+
const UNENCODED_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
|
|
24
|
+
"style",
|
|
25
|
+
"script",
|
|
26
|
+
"xmp",
|
|
27
|
+
"iframe",
|
|
28
|
+
"noembed",
|
|
29
|
+
"noframes",
|
|
30
|
+
"plaintext",
|
|
31
|
+
"noscript",
|
|
32
|
+
"textarea",
|
|
33
|
+
"title"
|
|
34
|
+
]);
|
|
35
|
+
function refusesValueBinding(tag) {
|
|
36
|
+
return UNENCODED_TEXT_ELEMENTS.has(tag);
|
|
37
|
+
}
|
|
38
|
+
function assertDeclarationLimit(declarations, subject) {
|
|
39
|
+
if (declarations.length > 200) throw new RangeError(`${subject} declares ${declarations.length} slots; the limit is 200.`);
|
|
40
|
+
}
|
|
41
|
+
function parsedHostDeclarations(options) {
|
|
42
|
+
const declarations = options.declarations === void 0 ? [] : SlotsInput.parse(options.declarations);
|
|
43
|
+
assertDeclarationLimit(declarations, "The host");
|
|
44
|
+
return declarations;
|
|
45
|
+
}
|
|
46
|
+
function mergedDeclarations(groups, options) {
|
|
47
|
+
groups.forEach((group, index) => {
|
|
48
|
+
assertDeclarationLimit(group, groups.length === 1 ? "The item" : `Item ${index + 1}`);
|
|
49
|
+
});
|
|
50
|
+
return mergeSlotDeclarations(parsedHostDeclarations(options), ...groups);
|
|
51
|
+
}
|
|
52
|
+
function applyText(element, name, data) {
|
|
53
|
+
const path = element.attribs[name];
|
|
54
|
+
if (path === void 0) return;
|
|
55
|
+
delete element.attribs[name];
|
|
56
|
+
const value = data[path];
|
|
57
|
+
if (value === void 0) return;
|
|
58
|
+
const text = new Text(value);
|
|
59
|
+
text.parent = element;
|
|
60
|
+
element.children = [text];
|
|
61
|
+
if (element.attribs.dir === void 0) element.attribs.dir = "auto";
|
|
62
|
+
}
|
|
63
|
+
function invalidBinding(element, names, report) {
|
|
64
|
+
const tag = element.name.toLowerCase();
|
|
65
|
+
const value = element.attribs[names.value];
|
|
66
|
+
if (value !== void 0 && report.slots[value] !== void 0 && refusesValueBinding(tag)) {
|
|
67
|
+
report.slots[value] = "invalid";
|
|
68
|
+
return `Slot "${value}" uses a value binding on <${tag}>, whose raw-text content cannot be filled safely.`;
|
|
69
|
+
}
|
|
70
|
+
const src = element.attribs[names.src];
|
|
71
|
+
if (src !== void 0 && report.slots[src] !== void 0 && tag !== "img") {
|
|
72
|
+
report.slots[src] = "invalid";
|
|
73
|
+
return `Slot "${src}" uses a src binding on <${tag}>; src bindings are allowed only on <img>.`;
|
|
74
|
+
}
|
|
75
|
+
const href = element.attribs[names.href];
|
|
76
|
+
if (href !== void 0 && report.slots[href] !== void 0 && tag !== "a" && tag !== "area") {
|
|
77
|
+
report.slots[href] = "invalid";
|
|
78
|
+
return `Slot "${href}" uses an href binding on <${tag}>; href bindings are allowed only on <a> and <area>.`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function applyUrl(element, attribute, name, resolved) {
|
|
82
|
+
const path = element.attribs[name];
|
|
83
|
+
if (path === void 0) return;
|
|
84
|
+
delete element.attribs[name];
|
|
85
|
+
const value = resolved.data[path];
|
|
86
|
+
if (typeof value !== "string") return;
|
|
87
|
+
if (attribute === "src") {
|
|
88
|
+
if (/^data:image\//i.test(value)) element.attribs.src = value;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
if (resolved.linkOrigins.has(new URL(value).origin)) element.attribs.href = value;
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
function applyElement(element, names, resolved) {
|
|
96
|
+
applyText(element, names.value, resolved.data);
|
|
97
|
+
applyUrl(element, "href", names.href, resolved);
|
|
98
|
+
applyUrl(element, "src", names.src, resolved);
|
|
99
|
+
for (const child of element.children) if (isTag(child)) applyElement(child, names, resolved);
|
|
100
|
+
}
|
|
101
|
+
function bindingRefusal(roots, names, resolved) {
|
|
102
|
+
const reasons = [];
|
|
103
|
+
const visit = (element) => {
|
|
104
|
+
const reason = invalidBinding(element, names, resolved.report);
|
|
105
|
+
if (reason) reasons.push(reason);
|
|
106
|
+
for (const child of element.children) if (isTag(child)) visit(child);
|
|
107
|
+
};
|
|
108
|
+
for (const root of roots) visit(root);
|
|
109
|
+
if (reasons.length === 0) return void 0;
|
|
110
|
+
resolved.reportState?.(resolved.report);
|
|
111
|
+
return new RangeError(reasons.join(" "));
|
|
112
|
+
}
|
|
113
|
+
function validString(type, value) {
|
|
114
|
+
if (type === "email") return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
115
|
+
if (type === "link") try {
|
|
116
|
+
return new URL(value).protocol === "https:";
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (type === "image") return /^data:image\//i.test(value);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
function assetResolver(options, signal) {
|
|
124
|
+
const cached = /* @__PURE__ */ new Map();
|
|
125
|
+
const waiting = [];
|
|
126
|
+
let running = 0;
|
|
127
|
+
const acquire = () => {
|
|
128
|
+
if (running < ASSET_RESOLUTION_CONCURRENCY) {
|
|
129
|
+
running++;
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
}
|
|
132
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
133
|
+
};
|
|
134
|
+
const release = () => {
|
|
135
|
+
const next = waiting.shift();
|
|
136
|
+
if (next) next();
|
|
137
|
+
else running--;
|
|
138
|
+
};
|
|
139
|
+
return (ref) => {
|
|
140
|
+
const held = cached.get(ref);
|
|
141
|
+
if (held) return held;
|
|
142
|
+
const resolution = (async () => {
|
|
143
|
+
if (!options.resolveAsset) return void 0;
|
|
144
|
+
await acquire();
|
|
145
|
+
try {
|
|
146
|
+
return await options.resolveAsset(ref, { signal });
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
} finally {
|
|
150
|
+
release();
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
cached.set(ref, resolution);
|
|
154
|
+
return resolution;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async function valueFor(slot, value, resolveAsset) {
|
|
158
|
+
if (value === void 0) {
|
|
159
|
+
if (slot.placeholder === void 0) return { state: "unavailable" };
|
|
160
|
+
return validString(slot.type, slot.placeholder) ? {
|
|
161
|
+
value: slot.placeholder,
|
|
162
|
+
state: "placeholder"
|
|
163
|
+
} : { state: "invalid" };
|
|
164
|
+
}
|
|
165
|
+
if (typeof value === "string") return validString(slot.type, value) ? {
|
|
166
|
+
value,
|
|
167
|
+
state: "filled"
|
|
168
|
+
} : { state: "invalid" };
|
|
169
|
+
if (slot.type !== "image" || !value || typeof value.asset !== "string") return { state: "invalid" };
|
|
170
|
+
const resolved = await resolveAsset(value.asset);
|
|
171
|
+
if (resolved === void 0) return { state: "unavailable" };
|
|
172
|
+
return /^data:image\//i.test(resolved) ? {
|
|
173
|
+
value: resolved,
|
|
174
|
+
state: "filled"
|
|
175
|
+
} : { state: "invalid" };
|
|
176
|
+
}
|
|
177
|
+
async function resolveSlots(declarations, options, signal) {
|
|
178
|
+
const data = {};
|
|
179
|
+
const states = {};
|
|
180
|
+
const linkOrigins = /* @__PURE__ */ new Set();
|
|
181
|
+
const resolveAsset = assetResolver(options, signal);
|
|
182
|
+
await Promise.all(declarations.map(async (slot) => {
|
|
183
|
+
const resolved = await valueFor(slot, options.fill?.values[slot.key], resolveAsset);
|
|
184
|
+
states[slot.key] = resolved.state;
|
|
185
|
+
if (resolved.value !== void 0) {
|
|
186
|
+
data[slot.key] = resolved.value;
|
|
187
|
+
if (slot.type === "link" && resolved.state === "filled") linkOrigins.add(new URL(resolved.value).origin);
|
|
188
|
+
}
|
|
189
|
+
}));
|
|
190
|
+
signal.throwIfAborted();
|
|
191
|
+
const report = {
|
|
192
|
+
fill: options.fill?.id ?? null,
|
|
193
|
+
slots: states
|
|
194
|
+
};
|
|
195
|
+
options.report?.(report);
|
|
196
|
+
return {
|
|
197
|
+
data,
|
|
198
|
+
report,
|
|
199
|
+
linkOrigins,
|
|
200
|
+
reportState: options.report
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function applySlots(html, resolved) {
|
|
204
|
+
if (Object.keys(resolved.report.slots).length === 0 || !hasBindings(html, "slot")) return html;
|
|
205
|
+
const names = bindingNames("slot");
|
|
206
|
+
const document = parse(html);
|
|
207
|
+
const bound = elements(document.children).filter((element) => Object.values(names).some((name) => element.attribs[name] !== void 0));
|
|
208
|
+
const roots = bound.filter((element) => {
|
|
209
|
+
for (let parent = element.parent; parent; parent = parent.parent) if (isTag(parent) && bound.includes(parent)) return false;
|
|
210
|
+
return true;
|
|
211
|
+
});
|
|
212
|
+
const refusal = bindingRefusal(roots, names, resolved);
|
|
213
|
+
if (refusal) throw refusal;
|
|
214
|
+
for (const root of roots) applyElement(root, names, resolved);
|
|
215
|
+
return roots.map((root) => ({
|
|
216
|
+
start: root.startIndex,
|
|
217
|
+
end: root.endIndex,
|
|
218
|
+
html: serialize(root)
|
|
219
|
+
})).filter((part) => part.start !== null && part.end !== null).sort((a, b) => b.start - a.start).reduce((output, part) => `${output.slice(0, part.start)}${part.html}${output.slice(part.end + 1)}`, html);
|
|
220
|
+
}
|
|
221
|
+
async function prepared(declarations, options, signal) {
|
|
222
|
+
const resolved = await resolveSlots(declarations, options, signal);
|
|
223
|
+
return {
|
|
224
|
+
report: resolved.report,
|
|
225
|
+
apply: (html) => applySlots(html, resolved)
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Prepares and applies the slots declared by one item. */
|
|
229
|
+
async function prepareSlots(declarations, options = {}, signal = new AbortController().signal) {
|
|
230
|
+
return prepared(mergedDeclarations([declarations ?? []], options), options, signal);
|
|
231
|
+
}
|
|
232
|
+
/** Prepares one shared fill for a collection, merging each item's declarations exactly once. */
|
|
233
|
+
async function prepareSlotCollection(groups, options = {}, signal = new AbortController().signal) {
|
|
234
|
+
return prepared(mergedDeclarations(groups.map((group) => group ?? []), options), options, signal);
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
export { MAX_SLOT_DECLARATIONS, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segnavia/html",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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.22.0",
|
|
30
30
|
"htmlparser2": "^10.0.0",
|
|
31
31
|
"domhandler": "^5.0.3",
|
|
32
32
|
"dom-serializer": "^2.0.0"
|