@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.
@@ -6,6 +6,7 @@ export interface FillOptions extends Pick<SanitizeOptions, 'linkOrigins'> {
6
6
  prefix: string;
7
7
  data: Record<string, unknown>;
8
8
  unavailable?: Iterable<string>;
9
+ preserveUnavailable?: boolean;
9
10
  }
10
11
  export interface Filled {
11
12
  html: string;
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(/&nbsp;/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) {
@@ -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.20.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.20.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"