@segnavia/html 0.22.0 → 0.24.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/index.d.ts CHANGED
@@ -2,10 +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";
5
7
  import { MAX_SLOT_DECLARATIONS, PreparedSlots, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal } from "./slots.js";
6
8
  //#region src/index.d.ts
7
9
  export type HtmlKind = 'plain' | 'live-data' | 'scripted' | 'refused';
8
10
  export declare function classify(html: string, prefix: string): HtmlKind;
9
11
  export declare function searchText(html: string): string;
10
12
  //#endregion
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 };
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,7 +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";
5
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";
6
8
  //#region src/index.ts
7
9
  function classify(html, prefix) {
8
10
  const scripted = elements(parse(html).children).some((element) => element.name.toLowerCase() === "script");
@@ -15,4 +17,4 @@ function searchText(html) {
15
17
  return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
16
18
  }
17
19
  //#endregion
18
- export { BINDINGS, MAX_SLOT_DECLARATIONS, REFUSED_ELEMENTS, SlotFillRefusedError, bindingNames, bookDocument, bookPrintPart, classify, fill, findActiveContent, hasBindings, hasSlotWork, prepareSlotCollection, prepareSlots, sanitize, searchText, slotFillRefusal };
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 };
@@ -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 };
package/dist/slots.d.ts CHANGED
@@ -1,11 +1,17 @@
1
+ import { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal } from "./slot-policy.js";
1
2
  import { Scripts, SlotDeclaration, SlotReport, SlotsOptions } from "@segnavia/format";
2
3
  //#region src/slots.d.ts
3
4
  export declare const MAX_SLOT_DECLARATIONS = 200;
4
5
  export interface PreparedSlots {
6
+ declarations: SlotDeclaration[];
5
7
  report: SlotReport;
8
+ /** For each slot key, how many applications (one per rendered frame) marked it for measurement. */
9
+ marked: ReadonlyMap<string, number>;
6
10
  apply(html: string): string;
11
+ mergeFit(slots: Record<string, 'fit' | 'shrunk' | 'overflowed'>): SlotReport;
7
12
  }
8
13
  export declare class SlotFillRefusedError extends Error {
14
+ readonly scripts: Exclude<Scripts, 'refuse'>;
9
15
  constructor(scripts: Exclude<Scripts, 'refuse'>);
10
16
  }
11
17
  /** Whether an item needs slot preparation for declarations, filling, asset resolution or reporting. */
@@ -16,4 +22,5 @@ export declare function slotFillRefusal(scripts: Scripts, declarations: readonly
16
22
  export declare function prepareSlots(declarations: readonly SlotDeclaration[] | undefined, options?: SlotsOptions, signal?: AbortSignal): Promise<PreparedSlots>;
17
23
  /** Prepares one shared fill for a collection, merging each item's declarations exactly once. */
18
24
  export declare function prepareSlotCollection(groups: ReadonlyArray<readonly SlotDeclaration[] | undefined>, options?: SlotsOptions, signal?: AbortSignal): Promise<PreparedSlots>;
19
- //#endregion
25
+ //#endregion
26
+ export { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal };
package/dist/slots.js CHANGED
@@ -1,23 +1,27 @@
1
1
  import { elements, parse, serialize } from "./sanitize.js";
2
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";
3
5
  import { Text, isTag } from "domhandler";
4
6
  import { SlotsInput, mergeSlotDeclarations } from "@segnavia/format";
5
7
  //#region src/slots.ts
6
8
  const MAX_SLOT_DECLARATIONS = 200;
7
9
  const ASSET_RESOLUTION_CONCURRENCY = 4;
8
10
  var SlotFillRefusedError = class extends Error {
11
+ scripts;
9
12
  constructor(scripts) {
10
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.`);
11
14
  this.name = "SlotFillRefusedError";
15
+ this.scripts = scripts;
12
16
  }
13
17
  };
14
18
  /** Whether an item needs slot preparation for declarations, filling, asset resolution or reporting. */
15
19
  function hasSlotWork(declarations, options) {
16
- return Boolean(declarations?.length || options?.declarations?.length || options?.fill || options?.resolveAsset || options?.report);
20
+ return Boolean(declarations?.length || options?.declarations?.length || options?.fill || options?.source || options?.resolveAsset || options?.report);
17
21
  }
18
22
  /** Refuses exposing filled values to an item's own scripts. */
19
23
  function slotFillRefusal(scripts, declarations, options) {
20
- const fillsValues = options?.fill && (declarations?.length || options.declarations?.length);
24
+ const fillsValues = (options?.fill || options?.source) && (declarations?.length || options.declarations?.length);
21
25
  return scripts !== "refuse" && fillsValues ? new SlotFillRefusedError(scripts) : void 0;
22
26
  }
23
27
  const UNENCODED_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
@@ -32,8 +36,23 @@ const UNENCODED_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
32
36
  "textarea",
33
37
  "title"
34
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
+ ]);
35
54
  function refusesValueBinding(tag) {
36
- return UNENCODED_TEXT_ELEMENTS.has(tag);
55
+ return UNENCODED_TEXT_ELEMENTS.has(tag) || VOID_ELEMENTS.has(tag);
37
56
  }
38
57
  function assertDeclarationLimit(declarations, subject) {
39
58
  if (declarations.length > 200) throw new RangeError(`${subject} declares ${declarations.length} slots; the limit is 200.`);
@@ -49,12 +68,18 @@ function mergedDeclarations(groups, options) {
49
68
  });
50
69
  return mergeSlotDeclarations(parsedHostDeclarations(options), ...groups);
51
70
  }
52
- function applyText(element, name, data) {
71
+ function applyValue(element, name, resolved) {
53
72
  const path = element.attribs[name];
54
73
  if (path === void 0) return;
55
74
  delete element.attribs[name];
56
- const value = data[path];
75
+ const value = resolved.data[path];
57
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
+ }
58
83
  const text = new Text(value);
59
84
  text.parent = element;
60
85
  element.children = [text];
@@ -65,7 +90,7 @@ function invalidBinding(element, names, report) {
65
90
  const value = element.attribs[names.value];
66
91
  if (value !== void 0 && report.slots[value] !== void 0 && refusesValueBinding(tag)) {
67
92
  report.slots[value] = "invalid";
68
- return `Slot "${value}" uses a value binding on <${tag}>, whose raw-text content cannot be filled safely.`;
93
+ return `Slot "${value}" uses a value binding on <${tag}>, whose content cannot be filled safely.`;
69
94
  }
70
95
  const src = element.attribs[names.src];
71
96
  if (src !== void 0 && report.slots[src] !== void 0 && tag !== "img") {
@@ -92,11 +117,42 @@ function applyUrl(element, attribute, name, resolved) {
92
117
  if (resolved.linkOrigins.has(new URL(value).origin)) element.attribs.href = value;
93
118
  } catch {}
94
119
  }
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);
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
+ });
100
156
  }
101
157
  function bindingRefusal(roots, names, resolved) {
102
158
  const reasons = [];
@@ -112,6 +168,7 @@ function bindingRefusal(roots, names, resolved) {
112
168
  }
113
169
  function validString(type, value) {
114
170
  if (type === "email") return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
171
+ if (type === "qr") return qrPayload(value) !== void 0;
115
172
  if (type === "link") try {
116
173
  return new URL(value).protocol === "https:";
117
174
  } catch {
@@ -174,14 +231,32 @@ async function valueFor(slot, value, resolveAsset) {
174
231
  state: "filled"
175
232
  } : { state: "invalid" };
176
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
+ }
177
247
  async function resolveSlots(declarations, options, signal) {
178
248
  const data = {};
249
+ const types = {};
179
250
  const states = {};
251
+ const origins = {};
180
252
  const linkOrigins = /* @__PURE__ */ new Set();
181
253
  const resolveAsset = assetResolver(options, signal);
182
254
  await Promise.all(declarations.map(async (slot) => {
183
- const resolved = await valueFor(slot, options.fill?.values[slot.key], resolveAsset);
255
+ types[slot.key] = slot.type;
256
+ const selected = layeredValue(slot.key, options);
257
+ const resolved = await valueFor(slot, selected.value, resolveAsset);
184
258
  states[slot.key] = resolved.state;
259
+ if (selected.origin) origins[slot.key] = selected.origin;
185
260
  if (resolved.value !== void 0) {
186
261
  data[slot.key] = resolved.value;
187
262
  if (slot.type === "link" && resolved.state === "filled") linkOrigins.add(new URL(resolved.value).origin);
@@ -189,41 +264,58 @@ async function resolveSlots(declarations, options, signal) {
189
264
  }));
190
265
  signal.throwIfAborted();
191
266
  const report = {
192
- fill: options.fill?.id ?? null,
193
- slots: states
267
+ fill: options.fill?.id ?? options.source?.id ?? null,
268
+ slots: states,
269
+ origins
194
270
  };
195
271
  options.report?.(report);
196
272
  return {
197
273
  data,
274
+ types,
198
275
  report,
199
276
  linkOrigins,
200
- reportState: options.report
277
+ reportState: options.report,
278
+ declarations: new Map(declarations.map((slot) => [slot.key, slot])),
279
+ marked: /* @__PURE__ */ new Map(),
280
+ markedHere: /* @__PURE__ */ new Set()
201
281
  };
202
282
  }
203
283
  function applySlots(html, resolved) {
204
- if (Object.keys(resolved.report.slots).length === 0 || !hasBindings(html, "slot")) return html;
284
+ if (!hasBindings(html, "slot") && !html.includes("data-slot-overflow")) return html;
205
285
  const names = bindingNames("slot");
206
286
  const document = parse(html);
207
- const bound = elements(document.children).filter((element) => Object.values(names).some((name) => element.attribs[name] !== void 0));
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));
208
288
  const roots = bound.filter((element) => {
209
289
  for (let parent = element.parent; parent; parent = parent.parent) if (isTag(parent) && bound.includes(parent)) return false;
210
290
  return true;
211
291
  });
212
292
  const refusal = bindingRefusal(roots, names, resolved);
213
293
  if (refusal) throw refusal;
214
- for (const root of roots) applyElement(root, names, resolved);
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);
215
297
  return roots.map((root) => ({
216
298
  start: root.startIndex,
217
299
  end: root.endIndex,
218
- html: serialize(root)
300
+ html: kept.includes(root) ? serialize(root) : ""
219
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);
220
302
  }
221
303
  async function prepared(declarations, options, signal) {
222
304
  const resolved = await resolveSlots(declarations, options, signal);
223
- return {
305
+ const result = {
306
+ declarations,
224
307
  report: resolved.report,
225
- apply: (html) => applySlots(html, resolved)
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
+ }
226
317
  };
318
+ return result;
227
319
  }
228
320
  /** Prepares and applies the slots declared by one item. */
229
321
  async function prepareSlots(declarations, options = {}, signal = new AbortController().signal) {
@@ -234,4 +326,4 @@ async function prepareSlotCollection(groups, options = {}, signal = new AbortCon
234
326
  return prepared(mergedDeclarations(groups.map((group) => group ?? []), options), options, signal);
235
327
  }
236
328
  //#endregion
237
- export { MAX_SLOT_DECLARATIONS, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal };
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.22.0",
3
+ "version": "0.24.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.22.0",
30
- "htmlparser2": "^10.0.0",
29
+ "@segnavia/format": "0.24.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
  }