@segnavia/html 0.21.0 → 0.23.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,12 @@ 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 { SlotFillEntry, SlotFillRefusal, SlotFillsConflictError, SlotFillsRefusedError, SlotPrintOptions, assertSingleSlotCopy, assertSlotPrintOptions, collectSlotPrintCopies, isSlotCopyRefusal, slotFillCopyId, slotFillsConflict } from "./slot-print.js";
6
+ import { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal } from "./slot-policy.js";
7
+ import { MAX_SLOT_DECLARATIONS, PreparedSlots, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal } from "./slots.js";
5
8
  //#region src/index.d.ts
6
9
  export type HtmlKind = 'plain' | 'live-data' | 'scripted' | 'refused';
7
10
  export declare function classify(html: string, prefix: string): HtmlKind;
8
11
  export declare function searchText(html: string): string;
9
12
  //#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 };
13
+ export { BINDINGS, type BookDocument, type BookDocumentOptions, type BookHeading, type BookPrintPart, type BookSectionInfo, type FillOptions, type Filled, MAX_SLOT_DECLARATIONS, type PreparedSlots, REFUSED_ELEMENTS, RequiredSlotsRefusedError, type SanitizeOptions, type SlotFillEntry, type SlotFillRefusal, SlotFillRefusedError, SlotFillsConflictError, SlotFillsRefusedError, type SlotPrintOptions, UnmeasuredSlotsRefusedError, assertSingleSlotCopy, assertSlotPrintOptions, bindingNames, bookDocument, bookPrintPart, collectSlotPrintCopies, fill, findActiveContent, hasBindings, hasSlotWork, isSlotCopyRefusal, mergeSlotFitResults, prepareSlotCollection, prepareSlots, requiredSlotRefusal, sanitize, slotFillCopyId, slotFillRefusal, slotFillsConflict, unmeasuredSlotRefusal };
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@ 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 { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal } from "./slot-policy.js";
6
+ import { MAX_SLOT_DECLARATIONS, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal } from "./slots.js";
7
+ import { SlotFillsConflictError, SlotFillsRefusedError, assertSingleSlotCopy, assertSlotPrintOptions, collectSlotPrintCopies, isSlotCopyRefusal, slotFillCopyId, slotFillsConflict } from "./slot-print.js";
5
8
  //#region src/index.ts
6
9
  function classify(html, prefix) {
7
10
  const scripted = elements(parse(html).children).some((element) => element.name.toLowerCase() === "script");
@@ -14,4 +17,4 @@ function searchText(html) {
14
17
  return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
15
18
  }
16
19
  //#endregion
17
- export { BINDINGS, REFUSED_ELEMENTS, bindingNames, bookDocument, bookPrintPart, classify, fill, findActiveContent, hasBindings, sanitize, searchText };
20
+ export { BINDINGS, MAX_SLOT_DECLARATIONS, REFUSED_ELEMENTS, RequiredSlotsRefusedError, SlotFillRefusedError, SlotFillsConflictError, SlotFillsRefusedError, UnmeasuredSlotsRefusedError, assertSingleSlotCopy, assertSlotPrintOptions, bindingNames, bookDocument, bookPrintPart, classify, collectSlotPrintCopies, fill, findActiveContent, hasBindings, hasSlotWork, isSlotCopyRefusal, mergeSlotFitResults, prepareSlotCollection, prepareSlots, requiredSlotRefusal, sanitize, searchText, slotFillCopyId, slotFillRefusal, slotFillsConflict, unmeasuredSlotRefusal };
package/dist/qr.js ADDED
@@ -0,0 +1,39 @@
1
+ import { Element } from "domhandler";
2
+ import qrcode from "qrcode-generator";
3
+ //#region src/qr.ts
4
+ const QR_PAYLOAD_LIMIT = 4096;
5
+ function qrPayload(value) {
6
+ try {
7
+ const url = new URL(value);
8
+ if (url.protocol !== "https:" || url.href.length > QR_PAYLOAD_LIMIT) return void 0;
9
+ const code = qrcode(0, "M");
10
+ code.addData(url.href);
11
+ code.make();
12
+ return url.href;
13
+ } catch {
14
+ return;
15
+ }
16
+ }
17
+ /** A QR code of the link at level M, drawn in currentColor with a four-module quiet zone. */
18
+ function qrSvg(value) {
19
+ const code = qrcode(0, "M");
20
+ code.addData(qrPayload(value) ?? value);
21
+ code.make();
22
+ const modules = code.getModuleCount();
23
+ const children = [];
24
+ for (let row = 0; row < modules; row++) for (let column = 0; column < modules; column++) if (code.isDark(row, column)) children.push(new Element("rect", {
25
+ x: String(column + 4),
26
+ y: String(row + 4),
27
+ width: "1",
28
+ height: "1",
29
+ fill: "currentColor"
30
+ }));
31
+ return new Element("svg", {
32
+ viewBox: `0 0 ${modules + 8} ${modules + 8}`,
33
+ "shape-rendering": "crispEdges",
34
+ role: "img",
35
+ "aria-label": value
36
+ }, children);
37
+ }
38
+ //#endregion
39
+ export { qrPayload, qrSvg };
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,27 @@
1
+ import { SlotDeclaration, SlotReport, SlotState } from "@segnavia/format";
2
+ //#region src/slot-policy.d.ts
3
+ export declare class RequiredSlotsRefusedError extends Error {
4
+ readonly slots: ReadonlyArray<{
5
+ key: string;
6
+ state: SlotState;
7
+ }>;
8
+ constructor(slots: ReadonlyArray<{
9
+ key: string;
10
+ state: SlotState;
11
+ }>);
12
+ }
13
+ export declare class UnmeasuredSlotsRefusedError extends Error {
14
+ readonly keys: readonly string[];
15
+ constructor(slots: readonly string[]);
16
+ }
17
+ /** Returns the print/render refusal for required or overflowing declarations. */
18
+ export declare function requiredSlotRefusal(declarations: readonly SlotDeclaration[], report: SlotReport): RequiredSlotsRefusedError | undefined;
19
+ /**
20
+ * Refuses filled refuse-overflow slots that some frame marked but did not report before output. Counts are per key:
21
+ * frames that marked it, against fit reports received, so one frame's report cannot stand in for another's.
22
+ * A slot no frame binds has nothing to measure.
23
+ */
24
+ export declare function unmeasuredSlotRefusal(declarations: readonly SlotDeclaration[], report: SlotReport, marked: ReadonlyMap<string, number>, measured: ReadonlyMap<string, number>): UnmeasuredSlotsRefusedError | undefined;
25
+ /** Merges measured fit states into filled declared slots; any other state, and any undeclared key, is left as it was. */
26
+ export declare function mergeSlotFitResults(declarations: readonly SlotDeclaration[], report: SlotReport, slots: Record<string, 'fit' | 'shrunk' | 'overflowed'>): SlotReport;
27
+ //#endregion
@@ -0,0 +1,61 @@
1
+ //#region src/slot-policy.ts
2
+ var RequiredSlotsRefusedError = class extends Error {
3
+ slots;
4
+ constructor(slots) {
5
+ super(`Output was refused because slots prevent printing: ${slots.map(({ key, state }) => `"${key}" (${state})`).join(", ")}. Fill required slots; for an overflowed refuse slot, shorten its value or change the slot's overflow.`);
6
+ this.name = "RequiredSlotsRefusedError";
7
+ this.slots = slots;
8
+ }
9
+ };
10
+ var UnmeasuredSlotsRefusedError = class extends Error {
11
+ keys;
12
+ constructor(slots) {
13
+ super(`Output was refused because overflow could not be measured for slots: ${slots.map((key) => `"${key}"`).join(", ")}. Ensure each filled slot frame becomes ready before the output deadline, or change the slot's overflow from 'refuse'.`);
14
+ this.name = "UnmeasuredSlotsRefusedError";
15
+ this.keys = slots;
16
+ }
17
+ };
18
+ /** Returns the print/render refusal for required or overflowing declarations. */
19
+ function requiredSlotRefusal(declarations, report) {
20
+ const refused = declarations.flatMap((slot) => {
21
+ const state = report.slots[slot.key];
22
+ const required = slot.required && state !== "filled" && state !== "fit" && state !== "shrunk" && state !== void 0;
23
+ const overflow = slot.overflow === "refuse" && state === "overflowed";
24
+ return required || overflow ? [{
25
+ key: slot.key,
26
+ state
27
+ }] : [];
28
+ });
29
+ return refused.length === 0 ? void 0 : new RequiredSlotsRefusedError(refused);
30
+ }
31
+ /**
32
+ * Refuses filled refuse-overflow slots that some frame marked but did not report before output. Counts are per key:
33
+ * frames that marked it, against fit reports received, so one frame's report cannot stand in for another's.
34
+ * A slot no frame binds has nothing to measure.
35
+ */
36
+ function unmeasuredSlotRefusal(declarations, report, marked, measured) {
37
+ const unmeasured = declarations.flatMap((slot) => slot.overflow === "refuse" && report.slots[slot.key] !== "overflowed" && (measured.get(slot.key) ?? 0) < (marked.get(slot.key) ?? 0) ? [slot.key] : []);
38
+ return unmeasured.length === 0 ? void 0 : new UnmeasuredSlotsRefusedError(unmeasured);
39
+ }
40
+ /** Merges measured fit states into filled declared slots; any other state, and any undeclared key, is left as it was. */
41
+ function mergeSlotFitResults(declarations, report, slots) {
42
+ const declared = new Set(declarations.map((slot) => slot.key));
43
+ const merged = { ...report.slots };
44
+ const rank = {
45
+ fit: 0,
46
+ shrunk: 1,
47
+ overflowed: 2
48
+ };
49
+ for (const [key, state] of Object.entries(slots)) {
50
+ if (!declared.has(key)) continue;
51
+ const previous = merged[key];
52
+ if (previous === "filled") merged[key] = state;
53
+ else if ((previous === "fit" || previous === "shrunk" || previous === "overflowed") && rank[state] > rank[previous]) merged[key] = state;
54
+ }
55
+ return {
56
+ ...report,
57
+ slots: merged
58
+ };
59
+ }
60
+ //#endregion
61
+ export { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal };
@@ -0,0 +1,40 @@
1
+ import { Fill, SlotsOptions } from "@segnavia/format";
2
+ //#region src/slot-print.d.ts
3
+ /** One copy of a print/PDF item: that copy's hand layer over its source layer. */
4
+ export interface SlotFillEntry {
5
+ fill?: Fill;
6
+ source?: Fill;
7
+ }
8
+ export interface SlotPrintOptions extends SlotsOptions {
9
+ fills?: SlotFillEntry[];
10
+ /** When set, copies that would be refused are reported and omitted; the job still refuses if none remain. */
11
+ skipRefused?: boolean;
12
+ }
13
+ export declare class SlotFillsConflictError extends Error {
14
+ constructor();
15
+ }
16
+ export interface SlotFillRefusal {
17
+ index: number;
18
+ id: string;
19
+ slots: ReadonlyArray<{
20
+ key: string;
21
+ state: string;
22
+ }>;
23
+ error: Error;
24
+ }
25
+ export declare class SlotFillsRefusedError extends Error {
26
+ readonly entries: readonly SlotFillRefusal[];
27
+ constructor(entries: readonly SlotFillRefusal[]);
28
+ }
29
+ export declare function slotFillCopyId(entry: SlotFillEntry, index: number): string;
30
+ export declare function slotFillsConflict(options: SlotPrintOptions | undefined): SlotFillsConflictError | undefined;
31
+ export declare function assertSlotPrintOptions(options: SlotPrintOptions | undefined): void;
32
+ /** Single-copy fill paths refuse `fills`; print and PDF jobs loop it themselves. */
33
+ export declare function assertSingleSlotCopy(options: SlotsOptions | undefined): void;
34
+ export declare function isSlotCopyRefusal(error: unknown): error is Error;
35
+ /**
36
+ * Runs one print/PDF copy per `fills` entry, in order. Without `fills`, runs once with the given options.
37
+ * Host `report` is called once per entry, in entry order, including refused copies.
38
+ */
39
+ export declare function collectSlotPrintCopies<T>(options: SlotPrintOptions | undefined, renderCopy: (copy: SlotsOptions) => Promise<T>): Promise<T[]>;
40
+ //#endregion
@@ -0,0 +1,142 @@
1
+ import { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError } from "./slot-policy.js";
2
+ import { SlotFillRefusedError } from "./slots.js";
3
+ //#region src/slot-print.ts
4
+ var SlotFillsConflictError = class extends Error {
5
+ constructor() {
6
+ super("slots.fills cannot be given together with slots.fill or slots.source. Pass only fills, or only fill and source, to clear this refusal.");
7
+ this.name = "SlotFillsConflictError";
8
+ }
9
+ };
10
+ var SlotFillsRefusedError = class extends Error {
11
+ entries;
12
+ constructor(entries) {
13
+ super(slotFillsRefusedMessage(entries));
14
+ this.name = "SlotFillsRefusedError";
15
+ this.entries = entries;
16
+ }
17
+ };
18
+ function slotFillCopyId(entry, index) {
19
+ return entry.fill?.id ?? String(index);
20
+ }
21
+ function slotFillsConflict(options) {
22
+ return options?.fills && (options.fill || options.source) ? new SlotFillsConflictError() : void 0;
23
+ }
24
+ function assertSlotPrintOptions(options) {
25
+ const conflict = slotFillsConflict(options);
26
+ if (conflict) throw conflict;
27
+ }
28
+ /** Single-copy fill paths refuse `fills`; print and PDF jobs loop it themselves. */
29
+ function assertSingleSlotCopy(options) {
30
+ const print = options;
31
+ assertSlotPrintOptions(print);
32
+ if (print?.fills) throw new RangeError("slots.fills prints one copy per entry in a PDF or print document. Pass fill or source for a single copy, to clear this refusal.");
33
+ }
34
+ function isSlotCopyRefusal(error) {
35
+ return error instanceof RequiredSlotsRefusedError || error instanceof UnmeasuredSlotsRefusedError || error instanceof SlotFillRefusedError;
36
+ }
37
+ function copySlots(options, entry, capture) {
38
+ return {
39
+ declarations: options.declarations,
40
+ resolveAsset: options.resolveAsset,
41
+ fill: entry.fill,
42
+ source: entry.source,
43
+ report: capture
44
+ };
45
+ }
46
+ function emptyReport(entry) {
47
+ return {
48
+ fill: entry.fill?.id ?? entry.source?.id ?? null,
49
+ slots: {},
50
+ origins: {}
51
+ };
52
+ }
53
+ function refusalFrom(error, entry, index) {
54
+ const id = slotFillCopyId(entry, index);
55
+ if (error instanceof RequiredSlotsRefusedError) return {
56
+ index,
57
+ id,
58
+ slots: error.slots,
59
+ error
60
+ };
61
+ if (error instanceof UnmeasuredSlotsRefusedError) return {
62
+ index,
63
+ id,
64
+ slots: error.keys.map((key) => ({
65
+ key,
66
+ state: "unmeasured"
67
+ })),
68
+ error
69
+ };
70
+ return {
71
+ index,
72
+ id,
73
+ slots: [],
74
+ error
75
+ };
76
+ }
77
+ function entryLabel(entry) {
78
+ return entry.id === String(entry.index) ? entry.id : `"${entry.id}"`;
79
+ }
80
+ function describeRefusal(entry) {
81
+ if (entry.error instanceof SlotFillRefusedError) return `${entryLabel(entry)} (scripts: '${entry.error.scripts}')`;
82
+ if (entry.slots.length === 0) return entryLabel(entry);
83
+ return `${entryLabel(entry)} ${entry.slots.map(({ key, state }) => `"${key}" (${state})`).join(", ")}`;
84
+ }
85
+ function clearsFor(entries) {
86
+ let required = false;
87
+ let unmeasured = false;
88
+ let scripts = false;
89
+ for (const entry of entries) if (entry.error instanceof RequiredSlotsRefusedError) required = true;
90
+ else if (entry.error instanceof UnmeasuredSlotsRefusedError) unmeasured = true;
91
+ else if (entry.error instanceof SlotFillRefusedError) scripts = true;
92
+ return [
93
+ required ? "Fill required slots; for an overflowed refuse slot, shorten its value or change the slot's overflow." : "",
94
+ unmeasured ? "Ensure each filled slot frame becomes ready before the output deadline, or change the slot's overflow from 'refuse'." : "",
95
+ scripts ? "Set the item's scripts to 'refuse', or show it without a fill." : ""
96
+ ].filter(Boolean).join(" ");
97
+ }
98
+ function slotFillsRefusedMessage(entries) {
99
+ if (entries.length === 0) return "Output was refused because slots.fills has no entries. Pass at least one fill, or omit fills, to clear this refusal.";
100
+ return `Output was refused because fills prevent printing: ${entries.map(describeRefusal).join("; ")}. ${clearsFor(entries)}`;
101
+ }
102
+ async function renderOneCopy(options, entry, index, renderCopy) {
103
+ let lastReport;
104
+ const capture = options.report ? (report) => {
105
+ lastReport = report;
106
+ } : void 0;
107
+ try {
108
+ const value = await renderCopy(copySlots(options, entry, capture));
109
+ options.report?.(lastReport ?? emptyReport(entry));
110
+ return {
111
+ ok: true,
112
+ value
113
+ };
114
+ } catch (error) {
115
+ if (!isSlotCopyRefusal(error)) throw error;
116
+ options.report?.(lastReport ?? emptyReport(entry));
117
+ return {
118
+ ok: false,
119
+ refusal: refusalFrom(error, entry, index)
120
+ };
121
+ }
122
+ }
123
+ /**
124
+ * Runs one print/PDF copy per `fills` entry, in order. Without `fills`, runs once with the given options.
125
+ * Host `report` is called once per entry, in entry order, including refused copies.
126
+ */
127
+ async function collectSlotPrintCopies(options, renderCopy) {
128
+ assertSlotPrintOptions(options);
129
+ if (!options?.fills) return [await renderCopy(options ?? {})];
130
+ if (options.fills.length === 0) throw new SlotFillsRefusedError([]);
131
+ const kept = [];
132
+ const refused = [];
133
+ for (const [index, entry] of options.fills.entries()) {
134
+ const outcome = await renderOneCopy(options, entry, index, renderCopy);
135
+ if (outcome.ok) kept.push(outcome.value);
136
+ else refused.push(outcome.refusal);
137
+ }
138
+ if (refused.length > 0 && (!options.skipRefused || kept.length === 0)) throw new SlotFillsRefusedError(refused);
139
+ return kept;
140
+ }
141
+ //#endregion
142
+ export { SlotFillsConflictError, SlotFillsRefusedError, assertSingleSlotCopy, assertSlotPrintOptions, collectSlotPrintCopies, isSlotCopyRefusal, slotFillCopyId, slotFillsConflict };
@@ -0,0 +1,26 @@
1
+ import { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal } from "./slot-policy.js";
2
+ import { Scripts, SlotDeclaration, SlotReport, SlotsOptions } from "@segnavia/format";
3
+ //#region src/slots.d.ts
4
+ export declare const MAX_SLOT_DECLARATIONS = 200;
5
+ export interface PreparedSlots {
6
+ declarations: SlotDeclaration[];
7
+ report: SlotReport;
8
+ /** For each slot key, how many applications (one per rendered frame) marked it for measurement. */
9
+ marked: ReadonlyMap<string, number>;
10
+ apply(html: string): string;
11
+ mergeFit(slots: Record<string, 'fit' | 'shrunk' | 'overflowed'>): SlotReport;
12
+ }
13
+ export declare class SlotFillRefusedError extends Error {
14
+ readonly scripts: Exclude<Scripts, 'refuse'>;
15
+ constructor(scripts: Exclude<Scripts, 'refuse'>);
16
+ }
17
+ /** Whether an item needs slot preparation for declarations, filling, asset resolution or reporting. */
18
+ export declare function hasSlotWork(declarations: readonly SlotDeclaration[] | undefined, options: SlotsOptions | undefined): boolean;
19
+ /** Refuses exposing filled values to an item's own scripts. */
20
+ export declare function slotFillRefusal(scripts: Scripts, declarations: readonly SlotDeclaration[] | undefined, options: SlotsOptions | undefined): SlotFillRefusedError | undefined;
21
+ /** Prepares and applies the slots declared by one item. */
22
+ export declare function prepareSlots(declarations: readonly SlotDeclaration[] | undefined, options?: SlotsOptions, signal?: AbortSignal): Promise<PreparedSlots>;
23
+ /** Prepares one shared fill for a collection, merging each item's declarations exactly once. */
24
+ export declare function prepareSlotCollection(groups: ReadonlyArray<readonly SlotDeclaration[] | undefined>, options?: SlotsOptions, signal?: AbortSignal): Promise<PreparedSlots>;
25
+ //#endregion
26
+ export { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal };
package/dist/slots.js ADDED
@@ -0,0 +1,329 @@
1
+ import { elements, parse, serialize } from "./sanitize.js";
2
+ import { bindingNames, hasBindings } from "./bindings.js";
3
+ import { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal } from "./slot-policy.js";
4
+ import { qrPayload, qrSvg } from "./qr.js";
5
+ import { Text, isTag } from "domhandler";
6
+ import { SlotsInput, mergeSlotDeclarations } from "@segnavia/format";
7
+ //#region src/slots.ts
8
+ const MAX_SLOT_DECLARATIONS = 200;
9
+ const ASSET_RESOLUTION_CONCURRENCY = 4;
10
+ var SlotFillRefusedError = class extends Error {
11
+ scripts;
12
+ constructor(scripts) {
13
+ 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.`);
14
+ this.name = "SlotFillRefusedError";
15
+ this.scripts = scripts;
16
+ }
17
+ };
18
+ /** Whether an item needs slot preparation for declarations, filling, asset resolution or reporting. */
19
+ function hasSlotWork(declarations, options) {
20
+ return Boolean(declarations?.length || options?.declarations?.length || options?.fill || options?.source || options?.resolveAsset || options?.report);
21
+ }
22
+ /** Refuses exposing filled values to an item's own scripts. */
23
+ function slotFillRefusal(scripts, declarations, options) {
24
+ const fillsValues = (options?.fill || options?.source) && (declarations?.length || options.declarations?.length);
25
+ return scripts !== "refuse" && fillsValues ? new SlotFillRefusedError(scripts) : void 0;
26
+ }
27
+ const UNENCODED_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
28
+ "style",
29
+ "script",
30
+ "xmp",
31
+ "iframe",
32
+ "noembed",
33
+ "noframes",
34
+ "plaintext",
35
+ "noscript",
36
+ "textarea",
37
+ "title"
38
+ ]);
39
+ const VOID_ELEMENTS = /* @__PURE__ */ new Set([
40
+ "area",
41
+ "base",
42
+ "br",
43
+ "col",
44
+ "embed",
45
+ "hr",
46
+ "img",
47
+ "input",
48
+ "link",
49
+ "meta",
50
+ "source",
51
+ "track",
52
+ "wbr"
53
+ ]);
54
+ function refusesValueBinding(tag) {
55
+ return UNENCODED_TEXT_ELEMENTS.has(tag) || VOID_ELEMENTS.has(tag);
56
+ }
57
+ function assertDeclarationLimit(declarations, subject) {
58
+ if (declarations.length > 200) throw new RangeError(`${subject} declares ${declarations.length} slots; the limit is 200.`);
59
+ }
60
+ function parsedHostDeclarations(options) {
61
+ const declarations = options.declarations === void 0 ? [] : SlotsInput.parse(options.declarations);
62
+ assertDeclarationLimit(declarations, "The host");
63
+ return declarations;
64
+ }
65
+ function mergedDeclarations(groups, options) {
66
+ groups.forEach((group, index) => {
67
+ assertDeclarationLimit(group, groups.length === 1 ? "The item" : `Item ${index + 1}`);
68
+ });
69
+ return mergeSlotDeclarations(parsedHostDeclarations(options), ...groups);
70
+ }
71
+ function applyValue(element, name, resolved) {
72
+ const path = element.attribs[name];
73
+ if (path === void 0) return;
74
+ delete element.attribs[name];
75
+ const value = resolved.data[path];
76
+ if (value === void 0) return;
77
+ if (resolved.types[path] === "qr") {
78
+ const svg = qrSvg(value);
79
+ svg.parent = element;
80
+ element.children = [svg];
81
+ return;
82
+ }
83
+ const text = new Text(value);
84
+ text.parent = element;
85
+ element.children = [text];
86
+ if (element.attribs.dir === void 0) element.attribs.dir = "auto";
87
+ }
88
+ function invalidBinding(element, names, report) {
89
+ const tag = element.name.toLowerCase();
90
+ const value = element.attribs[names.value];
91
+ if (value !== void 0 && report.slots[value] !== void 0 && refusesValueBinding(tag)) {
92
+ report.slots[value] = "invalid";
93
+ return `Slot "${value}" uses a value binding on <${tag}>, whose content cannot be filled safely.`;
94
+ }
95
+ const src = element.attribs[names.src];
96
+ if (src !== void 0 && report.slots[src] !== void 0 && tag !== "img") {
97
+ report.slots[src] = "invalid";
98
+ return `Slot "${src}" uses a src binding on <${tag}>; src bindings are allowed only on <img>.`;
99
+ }
100
+ const href = element.attribs[names.href];
101
+ if (href !== void 0 && report.slots[href] !== void 0 && tag !== "a" && tag !== "area") {
102
+ report.slots[href] = "invalid";
103
+ return `Slot "${href}" uses an href binding on <${tag}>; href bindings are allowed only on <a> and <area>.`;
104
+ }
105
+ }
106
+ function applyUrl(element, attribute, name, resolved) {
107
+ const path = element.attribs[name];
108
+ if (path === void 0) return;
109
+ delete element.attribs[name];
110
+ const value = resolved.data[path];
111
+ if (typeof value !== "string") return;
112
+ if (attribute === "src") {
113
+ if (/^data:image\//i.test(value)) element.attribs.src = value;
114
+ return;
115
+ }
116
+ try {
117
+ if (resolved.linkOrigins.has(new URL(value).origin)) element.attribs.href = value;
118
+ } catch {}
119
+ }
120
+ function applyPresent(element, name, report) {
121
+ const path = element.attribs[name];
122
+ if (path === void 0) return true;
123
+ delete element.attribs[name];
124
+ return report.fill === null || report.slots[path] === "filled";
125
+ }
126
+ function markOverflow(element, names, resolved) {
127
+ if (resolved.report.fill === null) return;
128
+ for (const binding of Object.values(names)) {
129
+ const key = element.attribs[binding];
130
+ if (key === void 0) continue;
131
+ const declaration = resolved.declarations.get(key);
132
+ const overflow = declaration?.overflow ?? "wrap";
133
+ if (!declaration || overflow === "wrap" || resolved.report.slots[key] !== "filled") continue;
134
+ element.attribs["data-slot-overflow"] = `${encodeURIComponent(key)}:${overflow}${overflow === "shrink" ? `:${declaration.minSize ?? 6}` : ""}`;
135
+ resolved.markedHere.add(key);
136
+ return;
137
+ }
138
+ }
139
+ function applyElements(elements, names, resolved) {
140
+ return elements.flatMap((element) => {
141
+ if (!applyPresent(element, names.present, resolved.report)) return [];
142
+ delete element.attribs["data-slot-overflow"];
143
+ markOverflow(element, names, resolved);
144
+ applyValue(element, names.value, resolved);
145
+ applyUrl(element, "href", names.href, resolved);
146
+ applyUrl(element, "src", names.src, resolved);
147
+ element.children = element.children.flatMap((child) => {
148
+ if (!isTag(child)) return [child];
149
+ return applyElements([child], names, resolved).map((kept) => {
150
+ kept.parent = element;
151
+ return kept;
152
+ });
153
+ });
154
+ return [element];
155
+ });
156
+ }
157
+ function bindingRefusal(roots, names, resolved) {
158
+ const reasons = [];
159
+ const visit = (element) => {
160
+ const reason = invalidBinding(element, names, resolved.report);
161
+ if (reason) reasons.push(reason);
162
+ for (const child of element.children) if (isTag(child)) visit(child);
163
+ };
164
+ for (const root of roots) visit(root);
165
+ if (reasons.length === 0) return void 0;
166
+ resolved.reportState?.(resolved.report);
167
+ return new RangeError(reasons.join(" "));
168
+ }
169
+ function validString(type, value) {
170
+ if (type === "email") return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
171
+ if (type === "qr") return qrPayload(value) !== void 0;
172
+ if (type === "link") try {
173
+ return new URL(value).protocol === "https:";
174
+ } catch {
175
+ return false;
176
+ }
177
+ if (type === "image") return /^data:image\//i.test(value);
178
+ return true;
179
+ }
180
+ function assetResolver(options, signal) {
181
+ const cached = /* @__PURE__ */ new Map();
182
+ const waiting = [];
183
+ let running = 0;
184
+ const acquire = () => {
185
+ if (running < ASSET_RESOLUTION_CONCURRENCY) {
186
+ running++;
187
+ return Promise.resolve();
188
+ }
189
+ return new Promise((resolve) => waiting.push(resolve));
190
+ };
191
+ const release = () => {
192
+ const next = waiting.shift();
193
+ if (next) next();
194
+ else running--;
195
+ };
196
+ return (ref) => {
197
+ const held = cached.get(ref);
198
+ if (held) return held;
199
+ const resolution = (async () => {
200
+ if (!options.resolveAsset) return void 0;
201
+ await acquire();
202
+ try {
203
+ return await options.resolveAsset(ref, { signal });
204
+ } catch {
205
+ return;
206
+ } finally {
207
+ release();
208
+ }
209
+ })();
210
+ cached.set(ref, resolution);
211
+ return resolution;
212
+ };
213
+ }
214
+ async function valueFor(slot, value, resolveAsset) {
215
+ if (value === void 0) {
216
+ if (slot.placeholder === void 0) return { state: "unavailable" };
217
+ return validString(slot.type, slot.placeholder) ? {
218
+ value: slot.placeholder,
219
+ state: "placeholder"
220
+ } : { state: "invalid" };
221
+ }
222
+ if (typeof value === "string") return validString(slot.type, value) ? {
223
+ value,
224
+ state: "filled"
225
+ } : { state: "invalid" };
226
+ if (slot.type !== "image" || !value || typeof value.asset !== "string") return { state: "invalid" };
227
+ const resolved = await resolveAsset(value.asset);
228
+ if (resolved === void 0) return { state: "unavailable" };
229
+ return /^data:image\//i.test(resolved) ? {
230
+ value: resolved,
231
+ state: "filled"
232
+ } : { state: "invalid" };
233
+ }
234
+ function layeredValue(key, options) {
235
+ const hand = options.fill?.values[key];
236
+ if (hand !== void 0 && hand !== "") return {
237
+ value: hand,
238
+ origin: "hand"
239
+ };
240
+ const source = options.source?.values[key];
241
+ if (source !== void 0 && source !== "") return {
242
+ value: source,
243
+ origin: "source"
244
+ };
245
+ return {};
246
+ }
247
+ async function resolveSlots(declarations, options, signal) {
248
+ const data = {};
249
+ const types = {};
250
+ const states = {};
251
+ const origins = {};
252
+ const linkOrigins = /* @__PURE__ */ new Set();
253
+ const resolveAsset = assetResolver(options, signal);
254
+ await Promise.all(declarations.map(async (slot) => {
255
+ types[slot.key] = slot.type;
256
+ const selected = layeredValue(slot.key, options);
257
+ const resolved = await valueFor(slot, selected.value, resolveAsset);
258
+ states[slot.key] = resolved.state;
259
+ if (selected.origin) origins[slot.key] = selected.origin;
260
+ if (resolved.value !== void 0) {
261
+ data[slot.key] = resolved.value;
262
+ if (slot.type === "link" && resolved.state === "filled") linkOrigins.add(new URL(resolved.value).origin);
263
+ }
264
+ }));
265
+ signal.throwIfAborted();
266
+ const report = {
267
+ fill: options.fill?.id ?? options.source?.id ?? null,
268
+ slots: states,
269
+ origins
270
+ };
271
+ options.report?.(report);
272
+ return {
273
+ data,
274
+ types,
275
+ report,
276
+ linkOrigins,
277
+ reportState: options.report,
278
+ declarations: new Map(declarations.map((slot) => [slot.key, slot])),
279
+ marked: /* @__PURE__ */ new Map(),
280
+ markedHere: /* @__PURE__ */ new Set()
281
+ };
282
+ }
283
+ function applySlots(html, resolved) {
284
+ if (!hasBindings(html, "slot") && !html.includes("data-slot-overflow")) return html;
285
+ const names = bindingNames("slot");
286
+ const document = parse(html);
287
+ const bound = elements(document.children).filter((element) => element.attribs["data-slot-overflow"] !== void 0 || Object.values(names).some((name) => element.attribs[name] !== void 0));
288
+ const roots = bound.filter((element) => {
289
+ for (let parent = element.parent; parent; parent = parent.parent) if (isTag(parent) && bound.includes(parent)) return false;
290
+ return true;
291
+ });
292
+ const refusal = bindingRefusal(roots, names, resolved);
293
+ if (refusal) throw refusal;
294
+ resolved.markedHere.clear();
295
+ const kept = applyElements(roots, names, resolved);
296
+ for (const key of resolved.markedHere) resolved.marked.set(key, (resolved.marked.get(key) ?? 0) + 1);
297
+ return roots.map((root) => ({
298
+ start: root.startIndex,
299
+ end: root.endIndex,
300
+ html: kept.includes(root) ? serialize(root) : ""
301
+ })).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);
302
+ }
303
+ async function prepared(declarations, options, signal) {
304
+ const resolved = await resolveSlots(declarations, options, signal);
305
+ const result = {
306
+ declarations,
307
+ report: resolved.report,
308
+ marked: resolved.marked,
309
+ apply: (html) => applySlots(html, resolved),
310
+ mergeFit: (slots) => {
311
+ const report = mergeSlotFitResults(declarations, result.report, slots);
312
+ result.report = report;
313
+ resolved.report = report;
314
+ options.report?.(report);
315
+ return report;
316
+ }
317
+ };
318
+ return result;
319
+ }
320
+ /** Prepares and applies the slots declared by one item. */
321
+ async function prepareSlots(declarations, options = {}, signal = new AbortController().signal) {
322
+ return prepared(mergedDeclarations([declarations ?? []], options), options, signal);
323
+ }
324
+ /** Prepares one shared fill for a collection, merging each item's declarations exactly once. */
325
+ async function prepareSlotCollection(groups, options = {}, signal = new AbortController().signal) {
326
+ return prepared(mergedDeclarations(groups.map((group) => group ?? []), options), options, signal);
327
+ }
328
+ //#endregion
329
+ export { MAX_SLOT_DECLARATIONS, RequiredSlotsRefusedError, SlotFillRefusedError, UnmeasuredSlotsRefusedError, hasSlotWork, mergeSlotFitResults, prepareSlotCollection, prepareSlots, requiredSlotRefusal, slotFillRefusal, unmeasuredSlotRefusal };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segnavia/html",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,9 +26,10 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@segnavia/format": "0.21.0",
30
- "htmlparser2": "^10.0.0",
29
+ "@segnavia/format": "0.23.0",
30
+ "dom-serializer": "^2.0.0",
31
31
  "domhandler": "^5.0.3",
32
- "dom-serializer": "^2.0.0"
32
+ "htmlparser2": "^10.0.0",
33
+ "qrcode-generator": "2.0.4"
33
34
  }
34
35
  }