@segnavia/html 0.37.0 → 0.39.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.js CHANGED
@@ -1,11 +1,14 @@
1
- import { REFUSED_ELEMENTS, elements, findActiveContent, parse, sanitize } from "./sanitize.js";
1
+ import { REFUSED_ELEMENTS, elements, findActiveContent, parse, safeImageSource, sanitize } from "./sanitize.js";
2
2
  import { BINDINGS, bindingNames, bindingRoot, fill, hasBindings } from "./bindings.js";
3
3
  import { base64ToBytes, bytesToBase64 } from "./base64.js";
4
4
  import { BOOK_COLUMN_MAX, BOOK_COLUMN_MIN, BOOK_FLOW_COLUMN_MIN_PX, parseBookPositionKey } from "./book/book-flow.js";
5
+ import { ABSOLUTE_PX, ABSOLUTE_UNITS, fitRegionSize, lengthPx, parseCssLength } from "./book/region-fit.js";
6
+ import { PAGE_REFERENCE_ATTRIBUTE, PAGE_REFERENCE_SELECTOR, PAGE_REFERENCE_VALUE, pageReferenceTarget } from "./book/book-page-reference.js";
5
7
  import { bookPrintPart } from "./book/types.js";
6
8
  import { bookDocument } from "./book/book-document.js";
7
9
  import { bookView, bookViewFromHtml } from "./book/book-view.js";
8
10
  import { embedFunction, fillBookFurniture, furnitureScript } from "./book/furniture.js";
11
+ import { planBookPages, planPrintedPages, printedPageAt, selectBookParent } from "./printed-pages.js";
9
12
  import { PAGINATE_FUNCTIONS, paginateBook, paginateScript } from "./book/paginate-book.js";
10
13
  import { heldPageNumber, openerPageIndex, pageIndexForTarget, windowPageIndex } from "./book/paginate-target.js";
11
14
  import { bookStylesheet, sanitizeBookCss } from "./book-css.js";
@@ -13,8 +16,8 @@ import { UNICODE_RANGE_CLEARANCE, parseUnicodeRange } from "./unicode-range.js";
13
16
  import { FontCssRefusedError, fontCss, fontFaceCss } from "./font-css.js";
14
17
  import { fontFormat } from "./font-format.js";
15
18
  import { fontReferenceRefusals } from "./font-references.js";
16
- import { MOTIF_DEPTH_LIMIT, MotifRefusedError, documentLanguage, expandMotifs, hasMotifPlacement, libraryMotifPlacementRefusal, motifPlacementRefusals, renderMotifPlacements } from "./motifs.js";
17
- import { planBookPages, planPrintedPages, printedPageAt, selectBookParent } from "./printed-pages.js";
19
+ import { MOTIF_DEPTH_LIMIT, MotifRefusedError, documentLanguage, expandMotifs, hasHostMotifPlacement, hasMotifPlacement, libraryMotifPlacementRefusal, motifPlacementRefusals, renderMotifPlacements } from "./motifs.js";
20
+ import { PLACEMENT_ATTRIBUTES, classifyMediaAnswer, placementCropEdges, placementPlaceholderSource, placementPlan, resolvePlacements } from "./placements.js";
18
21
  import { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal } from "./slot-policy.js";
19
22
  import { MAX_SLOT_DECLARATIONS, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal } from "./slots.js";
20
23
  import { SlotFillsConflictError, SlotFillsRefusedError, assertSingleSlotCopy, assertSlotPrintOptions, collectSlotPrintCopies, isSlotCopyRefusal, slotFillCopyId, slotFillsConflict } from "./slot-print.js";
@@ -30,4 +33,4 @@ function searchText(html) {
30
33
  return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
31
34
  }
32
35
  //#endregion
33
- export { BINDINGS, BOOK_COLUMN_MAX, BOOK_COLUMN_MIN, BOOK_FLOW_COLUMN_MIN_PX, FontCssRefusedError, MAX_SLOT_DECLARATIONS, MOTIF_DEPTH_LIMIT, MotifRefusedError, PAGINATE_FUNCTIONS, REFUSED_ELEMENTS, RequiredSlotsRefusedError, SlotFillRefusedError, SlotFillsConflictError, SlotFillsRefusedError, UNICODE_RANGE_CLEARANCE, UnmeasuredSlotsRefusedError, assertSingleSlotCopy, assertSlotPrintOptions, base64ToBytes, bindingNames, bindingRoot, bookDocument, bookPrintPart, bookStylesheet, bookView, bookViewFromHtml, bytesToBase64, classify, collectSlotPrintCopies, documentLanguage, embedFunction, expandMotifs, fill, fillBookFurniture, findActiveContent, fontCss, fontFaceCss, fontFormat, fontReferenceRefusals, furnitureScript, hasBindings, hasMotifPlacement, hasSlotWork, heldPageNumber, isSlotCopyRefusal, libraryMotifPlacementRefusal, mergeSlotFitResults, motifPlacementRefusals, openerPageIndex, pageIndexForTarget, paginateBook, paginateScript, parseBookPositionKey, parseUnicodeRange, planBookPages, planPrintedPages, prepareSlotCollection, prepareSlots, printedPageAt, renderMotifPlacements, requiredSlotRefusal, sanitize, sanitizeBookCss, searchText, selectBookParent, slotFillCopyId, slotFillRefusal, slotFillsConflict, unmeasuredSlotRefusal, windowPageIndex };
36
+ export { ABSOLUTE_PX, ABSOLUTE_UNITS, BINDINGS, BOOK_COLUMN_MAX, BOOK_COLUMN_MIN, BOOK_FLOW_COLUMN_MIN_PX, FontCssRefusedError, MAX_SLOT_DECLARATIONS, MOTIF_DEPTH_LIMIT, MotifRefusedError, PAGE_REFERENCE_ATTRIBUTE, PAGE_REFERENCE_SELECTOR, PAGE_REFERENCE_VALUE, PAGINATE_FUNCTIONS, PLACEMENT_ATTRIBUTES, REFUSED_ELEMENTS, RequiredSlotsRefusedError, SlotFillRefusedError, SlotFillsConflictError, SlotFillsRefusedError, UNICODE_RANGE_CLEARANCE, UnmeasuredSlotsRefusedError, assertSingleSlotCopy, assertSlotPrintOptions, base64ToBytes, bindingNames, bindingRoot, bookDocument, bookPrintPart, bookStylesheet, bookView, bookViewFromHtml, bytesToBase64, classify, classifyMediaAnswer, collectSlotPrintCopies, documentLanguage, embedFunction, expandMotifs, fill, fillBookFurniture, findActiveContent, fitRegionSize, fontCss, fontFaceCss, fontFormat, fontReferenceRefusals, furnitureScript, hasBindings, hasHostMotifPlacement, hasMotifPlacement, hasSlotWork, heldPageNumber, isSlotCopyRefusal, lengthPx, libraryMotifPlacementRefusal, mergeSlotFitResults, motifPlacementRefusals, openerPageIndex, pageIndexForTarget, pageReferenceTarget, paginateBook, paginateScript, parseBookPositionKey, parseCssLength, parseUnicodeRange, placementCropEdges, placementPlaceholderSource, placementPlan, planBookPages, planPrintedPages, prepareSlotCollection, prepareSlots, printedPageAt, renderMotifPlacements, requiredSlotRefusal, resolvePlacements, safeImageSource, sanitize, sanitizeBookCss, searchText, selectBookParent, slotFillCopyId, slotFillRefusal, slotFillsConflict, unmeasuredSlotRefusal, windowPageIndex };
package/dist/motifs.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface ExpandMotifsOptions {
11
11
  dataFonts?: boolean;
12
12
  signal: AbortSignal;
13
13
  deferPaths?: Iterable<string>;
14
+ deferLibrary?: boolean;
14
15
  }
15
16
  export interface PlacedMotif extends MotifPlacement {
16
17
  version: number;
@@ -26,6 +27,7 @@ export declare class MotifRefusedError extends Error {
26
27
  }
27
28
  export declare function libraryMotifPlacementRefusal(html: string): string | undefined;
28
29
  export declare function hasMotifPlacement(html: string): boolean;
30
+ export declare function hasHostMotifPlacement(html: string): boolean;
29
31
  export declare function renderMotifPlacements(html: string, replacement: (key: string) => string | undefined): string;
30
32
  export declare function documentLanguage(html: string): string | undefined;
31
33
  export declare function expandMotifs(html: string, options: ExpandMotifsOptions): Promise<ExpandedMotifs>;
package/dist/motifs.js CHANGED
@@ -26,6 +26,9 @@ function libraryMotifPlacementRefusal(html) {
26
26
  function hasMotifPlacement(html) {
27
27
  return motifElements(html).length > 0;
28
28
  }
29
+ function hasHostMotifPlacement(html) {
30
+ return motifElements(html).some((element) => !(element.attribs[MOTIF_ATTRIBUTE] ?? "").startsWith(LIBRARY_MOTIF_PREFIX));
31
+ }
29
32
  function renderMotifPlacements(html, replacement) {
30
33
  const document = parse(html);
31
34
  const visit = (nodes) => {
@@ -207,7 +210,7 @@ async function expandNodes(nodes, inheritedLang, stack, state) {
207
210
  for (const node of [...nodes]) {
208
211
  state.options.signal.throwIfAborted();
209
212
  if (!isTag(node)) continue;
210
- if (Object.hasOwn(node.attribs, MOTIF_ATTRIBUTE)) await expandPlacement(node, inheritedLang, stack, state);
213
+ if (Object.hasOwn(node.attribs, MOTIF_ATTRIBUTE) && !(state.options.deferLibrary && (node.attribs[MOTIF_ATTRIBUTE] ?? "").startsWith(LIBRARY_MOTIF_PREFIX))) await expandPlacement(node, inheritedLang, stack, state);
211
214
  else await expandNodes(node.children, inheritedLang, stack, state);
212
215
  }
213
216
  }
@@ -246,4 +249,4 @@ async function motifPlacementRefusals(html, options) {
246
249
  return (await run(html, options, true)).state.refusals;
247
250
  }
248
251
  //#endregion
249
- export { MOTIF_DEPTH_LIMIT, MotifRefusedError, documentLanguage, expandMotifs, hasMotifPlacement, libraryMotifPlacementRefusal, motifPlacementRefusals, renderMotifPlacements };
252
+ export { MOTIF_DEPTH_LIMIT, MotifRefusedError, documentLanguage, expandMotifs, hasHostMotifPlacement, hasMotifPlacement, libraryMotifPlacementRefusal, motifPlacementRefusals, renderMotifPlacements };
@@ -0,0 +1,89 @@
1
+ import { Media, Page, Placement } from "@segnavia/format";
2
+ //#region src/placements.d.ts
3
+ export declare const PLACEMENT_ATTRIBUTES: readonly ["data-segnavia-media", "data-segnavia-page", "data-segnavia-box", "data-segnavia-crop"];
4
+ export type ResolvedPlacement = {
5
+ kind: 'media';
6
+ media: Media;
7
+ src: string;
8
+ } | {
9
+ kind: 'page';
10
+ page: Page;
11
+ };
12
+ export type ResolvePlacement<R extends {
13
+ kind: string;
14
+ } = ResolvedPlacement> = (placement: Placement, context: {
15
+ purpose: 'screen' | 'print';
16
+ width?: number;
17
+ signal: AbortSignal;
18
+ }) => Promise<R | undefined>;
19
+ export interface PlacementPlanEntry {
20
+ index: number;
21
+ placement: Placement;
22
+ alt?: string;
23
+ lang?: string;
24
+ dir?: string;
25
+ width?: number;
26
+ }
27
+ export interface PlacementRefusal {
28
+ index: number;
29
+ address?: string;
30
+ page?: string | number;
31
+ message: string;
32
+ }
33
+ export interface PlacementPlan {
34
+ entries: PlacementPlanEntry[];
35
+ refusals: PlacementRefusal[];
36
+ }
37
+ export type PlacementReport = (entry: {
38
+ address: string | undefined;
39
+ page: string | number | undefined;
40
+ reason: 'missing' | 'refused-src' | 'invalid' | 'failed';
41
+ message?: string;
42
+ }) => void;
43
+ export interface PlacementsOptions<R extends {
44
+ kind: string;
45
+ } = ResolvedPlacement> {
46
+ resolve: ResolvePlacement<R>;
47
+ report?: PlacementReport;
48
+ }
49
+ type ResolvedMedia = Extract<ResolvedPlacement, {
50
+ kind: 'media';
51
+ }>;
52
+ export type MediaAnswer = {
53
+ status: 'ok';
54
+ value: ResolvedMedia;
55
+ } | {
56
+ status: 'missing' | 'refused-src';
57
+ message?: undefined;
58
+ } | {
59
+ status: 'invalid';
60
+ message: string;
61
+ } | {
62
+ status: 'failed';
63
+ message: string;
64
+ };
65
+ export declare function classifyMediaAnswer(input: {
66
+ answer: {
67
+ kind: string;
68
+ } | undefined;
69
+ }): Exclude<MediaAnswer, {
70
+ status: 'failed';
71
+ }>;
72
+ export declare function classifyMediaAnswer(input: {
73
+ error: unknown;
74
+ }): Extract<MediaAnswer, {
75
+ status: 'failed';
76
+ }>;
77
+ export declare function placementCropEdges(crop: Placement['crop']): string | undefined;
78
+ export declare function placementPlan(html: string): PlacementPlan;
79
+ export declare function placementPlaceholderSource(width?: number, height?: number): string;
80
+ export declare function resolvePlacements<R extends {
81
+ kind: string;
82
+ }>(html: string, options: {
83
+ resolvePlacement: ResolvePlacement<R>;
84
+ purpose: 'screen' | 'print';
85
+ width: number;
86
+ signal: AbortSignal;
87
+ report?: PlacementReport;
88
+ }): Promise<string>;
89
+ //#endregion
@@ -0,0 +1,189 @@
1
+ import { elements, parse, safeImageSource, serialize } from "./sanitize.js";
2
+ import { Placement, mediaTextDirection } from "@segnavia/format";
3
+ //#region src/placements.ts
4
+ const PLACEMENT_ATTRIBUTES = [
5
+ "data-segnavia-media",
6
+ "data-segnavia-page",
7
+ "data-segnavia-box",
8
+ "data-segnavia-crop"
9
+ ];
10
+ const [MEDIA_ATTRIBUTE, PAGE_ATTRIBUTE, BOX_ATTRIBUTE, CROP_ATTRIBUTE] = PLACEMENT_ATTRIBUTES;
11
+ function classifyMediaAnswer(input) {
12
+ if ("error" in input) return {
13
+ status: "failed",
14
+ message: input.error instanceof Error ? input.error.message : String(input.error)
15
+ };
16
+ const answer = input.answer;
17
+ if (!answer) return { status: "missing" };
18
+ if (answer.kind !== "media" || !("src" in answer) || !("media" in answer)) return {
19
+ status: "invalid",
20
+ message: `resolved ${answer.kind}, not media`
21
+ };
22
+ const media = answer;
23
+ return safeImageSource(media.src) ? {
24
+ status: "ok",
25
+ value: media
26
+ } : { status: "refused-src" };
27
+ }
28
+ function placementCropEdges(crop) {
29
+ if (!crop) return void 0;
30
+ return `${crop.top ?? 0} ${crop.right ?? 0} ${crop.bottom ?? 0} ${crop.left ?? 0}`;
31
+ }
32
+ function positiveInteger(value) {
33
+ return value !== void 0 && /^[1-9][0-9]*$/.test(value) ? Number(value) : void 0;
34
+ }
35
+ function pageSelector(value) {
36
+ if (value === void 0) return void 0;
37
+ return /^[1-9][0-9]*$/.test(value) ? Number(value) : value;
38
+ }
39
+ function cropValue(value) {
40
+ if (value === void 0) return void 0;
41
+ const edges = value.trim().split(/\s+/);
42
+ if (edges.length < 1 || edges.length > 4) return { top: value };
43
+ const [top, second, third, fourth] = edges;
44
+ return {
45
+ top,
46
+ right: second ?? top,
47
+ bottom: third ?? top,
48
+ left: fourth ?? second ?? top
49
+ };
50
+ }
51
+ function plannedElement(element, index) {
52
+ const address = element.attribs[MEDIA_ATTRIBUTE];
53
+ const page = pageSelector(element.attribs[PAGE_ATTRIBUTE]);
54
+ try {
55
+ return {
56
+ index,
57
+ placement: Placement.parse({
58
+ kind: "media",
59
+ address,
60
+ ...page === void 0 ? {} : { page },
61
+ ...element.attribs[BOX_ATTRIBUTE] === void 0 ? {} : { box: element.attribs[BOX_ATTRIBUTE] },
62
+ ...element.attribs[CROP_ATTRIBUTE] === void 0 ? {} : { crop: cropValue(element.attribs[CROP_ATTRIBUTE]) }
63
+ }, "page"),
64
+ ..."alt" in element.attribs ? { alt: element.attribs.alt } : {},
65
+ ..."lang" in element.attribs ? { lang: element.attribs.lang } : {},
66
+ ..."dir" in element.attribs ? { dir: element.attribs.dir } : {},
67
+ ...positiveInteger(element.attribs.width) === void 0 ? {} : { width: positiveInteger(element.attribs.width) }
68
+ };
69
+ } catch (error) {
70
+ return {
71
+ index,
72
+ address,
73
+ page,
74
+ message: error instanceof Error ? error.message : String(error)
75
+ };
76
+ }
77
+ }
78
+ function planElements(found) {
79
+ const planned = found.flatMap((element, index) => element.name.toLowerCase() === "img" && MEDIA_ATTRIBUTE in element.attribs ? [plannedElement(element, index)] : []);
80
+ return {
81
+ entries: planned.filter((entry) => "placement" in entry),
82
+ refusals: planned.filter((entry) => !("placement" in entry))
83
+ };
84
+ }
85
+ function placementPlan(html) {
86
+ return planElements(elements(parse(html).children));
87
+ }
88
+ function placementPlaceholderSource(width = 400, height = 300) {
89
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="#eee"/><rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" fill="none" stroke="#aaa"/></svg>`;
90
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
91
+ }
92
+ function placeholder(element) {
93
+ const authoredWidth = positiveInteger(element.attribs.width);
94
+ const authoredHeight = positiveInteger(element.attribs.height);
95
+ const width = authoredWidth ?? (authoredHeight === void 0 ? 400 : Math.round(authoredHeight * 4 / 3));
96
+ const height = authoredHeight ?? (authoredWidth === void 0 ? 300 : Math.round(authoredWidth * 3 / 4));
97
+ element.attribs.src = placementPlaceholderSource(width, height);
98
+ element.attribs["data-segnavia-placement"] = "missing";
99
+ if (!("alt" in element.attribs)) element.attribs.alt = "";
100
+ for (const attribute of PLACEMENT_ATTRIBUTES) delete element.attribs[attribute];
101
+ }
102
+ function applyResolved(element, entry, resolved) {
103
+ element.attribs.src = resolved.src;
104
+ if (entry.alt === void 0 && resolved.media.alt) {
105
+ element.attribs.alt = resolved.media.alt.text;
106
+ if (entry.lang === void 0 && entry.dir === void 0) {
107
+ if (resolved.media.alt.lang) element.attribs.lang = resolved.media.alt.lang;
108
+ element.attribs.dir = mediaTextDirection(resolved.media.alt);
109
+ }
110
+ }
111
+ const crop = placementCropEdges(entry.placement.crop);
112
+ if (crop) {
113
+ const declaration = `object-view-box: inset(${crop})`;
114
+ const authored = element.attribs.style?.trim();
115
+ element.attribs.style = authored ? `${authored.replace(/;?$/, ";")} ${declaration}` : declaration;
116
+ }
117
+ for (const attribute of PLACEMENT_ATTRIBUTES) delete element.attribs[attribute];
118
+ }
119
+ function abortable(work, signal) {
120
+ return new Promise((resolve, reject) => {
121
+ if (signal.aborted) {
122
+ reject(signal.reason);
123
+ return;
124
+ }
125
+ const aborted = () => reject(signal.reason);
126
+ signal.addEventListener("abort", aborted, { once: true });
127
+ work().then((value) => {
128
+ signal.removeEventListener("abort", aborted);
129
+ resolve(value);
130
+ }, (error) => {
131
+ signal.removeEventListener("abort", aborted);
132
+ reject(error);
133
+ });
134
+ });
135
+ }
136
+ async function resolvePlacements(html, options) {
137
+ const document = parse(html);
138
+ const found = elements(document.children);
139
+ const plan = planElements(found);
140
+ if (plan.entries.length === 0 && plan.refusals.length === 0) return html;
141
+ options.signal.throwIfAborted();
142
+ for (const refusal of plan.refusals) {
143
+ const element = found[refusal.index];
144
+ if (element) placeholder(element);
145
+ options.report?.({
146
+ address: refusal.address,
147
+ page: refusal.page,
148
+ reason: "invalid",
149
+ message: refusal.message
150
+ });
151
+ }
152
+ await Promise.all(plan.entries.map(async (entry) => {
153
+ const element = found[entry.index];
154
+ if (!element) return;
155
+ try {
156
+ const resolved = await abortable(() => options.resolvePlacement(entry.placement, {
157
+ purpose: options.purpose,
158
+ width: entry.width ?? options.width,
159
+ signal: options.signal
160
+ }), options.signal);
161
+ options.signal.throwIfAborted();
162
+ const classified = classifyMediaAnswer({ answer: resolved });
163
+ if (classified.status === "ok") applyResolved(element, entry, classified.value);
164
+ else {
165
+ placeholder(element);
166
+ options.report?.({
167
+ address: entry.placement.address,
168
+ page: entry.placement.page,
169
+ reason: classified.status,
170
+ ...classified.message === void 0 ? {} : { message: classified.message }
171
+ });
172
+ }
173
+ } catch (error) {
174
+ if (options.signal.aborted) throw options.signal.reason;
175
+ const classified = classifyMediaAnswer({ error });
176
+ placeholder(element);
177
+ options.report?.({
178
+ address: entry.placement.address,
179
+ page: entry.placement.page,
180
+ reason: classified.status,
181
+ message: classified.message
182
+ });
183
+ }
184
+ }));
185
+ options.signal.throwIfAborted();
186
+ return serialize(document);
187
+ }
188
+ //#endregion
189
+ export { PLACEMENT_ATTRIBUTES, classifyMediaAnswer, placementCropEdges, placementPlaceholderSource, placementPlan, resolvePlacements };
@@ -1,4 +1,4 @@
1
- import { BookSectionParents as BookSectionParents$1, NumberSystem, PrintedPageRole, PrintedPageRules } from "@segnavia/format";
1
+ import { BOOK_PARENT_NAMES, BookSectionParents as BookSectionParents$1, NumberSystem, PrintedPageRole, PrintedPageRules } from "@segnavia/format";
2
2
  //#region src/printed-pages.d.ts
3
3
  export type PrintedPageSide = 'left' | 'right';
4
4
  export interface PrintedPagePlan {
@@ -26,12 +26,7 @@ export interface BookPrintedPagePlan extends Omit<BookPageFacts, 'blank' | 'sect
26
26
  }
27
27
  export declare function printedPageAt(rules: PrintedPageRules, index: number, count: number, direction: 'ltr' | 'rtl'): Pick<PrintedPagePlan, 'role' | 'parent' | 'side'>;
28
28
  export declare function planPrintedPages(rules: PrintedPageRules, count: number, direction: 'ltr' | 'rtl', numbering?: NumberSystem): PrintedPagePlan[];
29
- export declare function selectBookParent(fact: BookPageFacts, index: number, count: number, direction: 'ltr' | 'rtl', names?: {
30
- readonly recto: "segnavia:recto";
31
- readonly verso: "segnavia:verso";
32
- readonly opener: "segnavia:opener";
33
- readonly blank: "segnavia:blank";
34
- }): Pick<BookPrintedPagePlan, 'role' | 'parent' | 'side'>;
29
+ export declare function selectBookParent(fact: BookPageFacts, index: number, count: number, direction: 'ltr' | 'rtl', names: typeof BOOK_PARENT_NAMES): Pick<BookPrintedPagePlan, 'role' | 'parent' | 'side'>;
35
30
  export declare function planBookPages(facts: BookPageFacts[], direction: 'ltr' | 'rtl', names?: {
36
31
  readonly recto: "segnavia:recto";
37
32
  readonly verso: "segnavia:verso";
@@ -39,7 +39,7 @@ function planPrintedPages(rules, count, direction, numbering = "arabic") {
39
39
  };
40
40
  });
41
41
  }
42
- function selectBookParent(fact, index, count, direction, names = BOOK_PARENT_NAMES) {
42
+ function selectBookParent(fact, index, count, direction, names) {
43
43
  const physical = printedPageAt({
44
44
  every: names.recto,
45
45
  recto: names.recto,
@@ -4,9 +4,11 @@ export declare const REFUSED_ELEMENTS: Set<string>;
4
4
  export interface SanitizeOptions {
5
5
  linkOrigins?: Iterable<string>;
6
6
  keepScripts?: boolean;
7
+ dependencyOrigins?: Iterable<string>;
7
8
  dataFonts?: boolean;
8
9
  refuseElements?: Iterable<string>;
9
10
  }
11
+ export declare function safeImageSource(value: string): boolean;
10
12
  export declare function findActiveContent(html: string, options?: SanitizeOptions): string[];
11
13
  export declare function sanitize(html: string, options?: SanitizeOptions): string;
12
14
  //#endregion
package/dist/sanitize.js CHANGED
@@ -1,25 +1,27 @@
1
1
  import { sanitizeCss } from "./sanitize-css.js";
2
2
  import { isTag } from "domhandler";
3
+ import { dependencyDecision, dependencyOriginSet, originSet } from "@segnavia/format";
3
4
  import render from "dom-serializer";
4
5
  import { parseDocument } from "htmlparser2";
5
6
  //#region src/sanitize.ts
6
- const REFUSED_ELEMENTS = /* @__PURE__ */ new Set([
7
- "script",
8
- "iframe",
9
- "frame",
10
- "frameset",
11
- "object",
12
- "embed",
13
- "applet",
14
- "base",
15
- "portal",
16
- "link",
17
- "form",
18
- "input",
19
- "button",
20
- "select",
21
- "textarea"
22
- ]);
7
+ const REFUSED = {
8
+ script: "always",
9
+ iframe: "always",
10
+ frame: "always",
11
+ frameset: "always",
12
+ object: "always",
13
+ embed: "always",
14
+ applet: "always",
15
+ base: "always",
16
+ portal: "always",
17
+ link: "always",
18
+ form: "unless-scripts",
19
+ input: "unless-scripts",
20
+ button: "unless-scripts",
21
+ select: "unless-scripts",
22
+ textarea: "unless-scripts"
23
+ };
24
+ const REFUSED_ELEMENTS = new Set(Object.keys(REFUSED));
23
25
  const REFUSED_SVG_ELEMENTS = /* @__PURE__ */ new Set([
24
26
  "script",
25
27
  "animate",
@@ -78,13 +80,8 @@ function insideSvg(element) {
78
80
  function normalizeUrl(value) {
79
81
  return [...value].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("").toLowerCase();
80
82
  }
81
- function origins(values) {
82
- const set = /* @__PURE__ */ new Set();
83
- for (const value of values ?? []) try {
84
- const url = new URL(value);
85
- if (url.protocol === "https:") set.add(url.origin.toLowerCase());
86
- } catch {}
87
- return set;
83
+ function safeImageSource(value) {
84
+ return DATA_IMAGE.test(normalizeUrl(value));
88
85
  }
89
86
  function safeUrl(attribute, value, allowed) {
90
87
  const url = normalizeUrl(value);
@@ -98,74 +95,115 @@ function safeUrl(attribute, value, allowed) {
98
95
  }
99
96
  }
100
97
  if (attribute === "srcset") return DATA_IMAGE_SRCSET.test(url);
101
- if (attribute === "src" || attribute === "poster") return DATA_IMAGE.test(url);
98
+ if (attribute === "src" || attribute === "poster") return safeImageSource(value);
102
99
  return false;
103
100
  }
104
101
  function refusedElement(element, refused, keepScripts) {
105
102
  const name = element.name.toLowerCase();
106
103
  if (name === "script") return !keepScripts;
104
+ if (name === "link" && keepScripts && element.attribs.rel?.toLowerCase() === "stylesheet") return false;
107
105
  if (refused.has(name)) return true;
108
106
  if (insideSvg(element) && REFUSED_SVG_ELEMENTS.has(name)) return true;
109
107
  return name === "meta" && Object.keys(element.attribs).some((attribute) => attribute.toLowerCase() === "http-equiv");
110
108
  }
111
- function refusedAttribute(name, value, allowed, keepScripts) {
109
+ function dependencyAttribute(element, attribute, value, options, dependencies) {
110
+ if (!options.keepScripts) return void 0;
111
+ const tag = element.name.toLowerCase();
112
+ if (!(tag === "script" && attribute === "src" || tag === "link" && element.attribs.rel?.toLowerCase() === "stylesheet" && attribute === "href")) return void 0;
113
+ const decision = dependencyDecision(value, dependencies);
114
+ return decision.allowed ? "" : decision.reason;
115
+ }
116
+ function refusedAttribute(element, name, value, allowed, options, dependencies) {
112
117
  const attribute = name.toLowerCase();
113
- if (attribute.startsWith("on")) return !keepScripts;
114
- return REFUSED_ATTRIBUTES.has(attribute) || URL_ATTRIBUTES.has(attribute) && !safeUrl(attribute, value, allowed);
118
+ if (attribute.startsWith("on")) return options.keepScripts ? void 0 : "";
119
+ const dependency = dependencyAttribute(element, attribute, value, options, dependencies);
120
+ if (dependency !== void 0) return dependency || void 0;
121
+ return REFUSED_ATTRIBUTES.has(attribute) || URL_ATTRIBUTES.has(attribute) && !safeUrl(attribute, value, allowed) ? "" : void 0;
115
122
  }
116
123
  function refusedSet(options) {
117
- return new Set([...REFUSED_ELEMENTS, ...options.refuseElements ?? []].map((name) => name.toLowerCase()));
124
+ const defaults = options.keepScripts ? Object.entries(REFUSED).filter(([, rule]) => rule === "always").map(([name]) => name) : REFUSED_ELEMENTS;
125
+ return new Set([...defaults, ...options.refuseElements ?? []].map((name) => name.toLowerCase()));
118
126
  }
119
127
  function styleText(element) {
120
128
  return element.children.map((child) => "data" in child ? child.data : "").join("");
121
129
  }
122
- function styleFindings(element, dataFonts) {
130
+ function dependencyCss(source, declarations, options, dependencies) {
131
+ if (!options.keepScripts) return sanitizeCss(source, declarations, options.dataFonts ?? false);
132
+ const restores = [];
133
+ const hold = (url) => {
134
+ if (!dependencyDecision(url, dependencies).allowed) return url;
135
+ return `data:image/segnavia-dependency-${restores.push(url) - 1},x`;
136
+ };
137
+ let marker = "segnavia-held-import";
138
+ while (source.includes(marker)) marker += "-x";
139
+ const imports = [];
140
+ let protectedCss = source.replace(/@import\s+(?:url\(\s*)?["']?(https:\/\/[^\s"')]+)["']?\s*\)?[^;]*;/gi, (rule, url) => {
141
+ if (hold(url) === url) return rule;
142
+ const index = imports.push(rule) - 1;
143
+ return `.${marker}-${index}{}`;
144
+ });
145
+ protectedCss = protectedCss.replace(/@font-face\s*\{[^}]*\}/gi, (block) => block.replace(/https:\/\/[^\s"')]+/gi, hold));
146
+ const result = sanitizeCss(protectedCss, declarations, options.dataFonts ?? false);
147
+ const restore = (value) => value.replace(/data:image\/segnavia-dependency-(\d+),x/g, (_, index) => restores[Number(index)] ?? "");
148
+ let css = restore(result.css);
149
+ for (const [index, rule] of imports.entries()) css = css.replace(`.${marker}-${index}{}`, rule);
150
+ return {
151
+ css,
152
+ removed: result.removed.map(restore)
153
+ };
154
+ }
155
+ function styleFindings(element, options, dependencies) {
123
156
  const found = [];
124
- if (element.name.toLowerCase() === "style") for (const construct of sanitizeCss(styleText(element), false, dataFonts).removed) found.push(`<style> ${construct}`);
157
+ if (element.name.toLowerCase() === "style") for (const construct of dependencyCss(styleText(element), false, options, dependencies).removed) found.push(`<style> ${construct}`);
125
158
  for (const [name, value] of Object.entries(element.attribs)) {
126
159
  if (name.toLowerCase() !== "style") continue;
127
- for (const construct of sanitizeCss(value, true, dataFonts).removed) found.push(`${element.name}[${name}] ${construct}`);
160
+ for (const construct of dependencyCss(value, true, options, dependencies).removed) found.push(`${element.name}[${name}] ${construct}`);
128
161
  }
129
162
  return found;
130
163
  }
131
164
  function findActiveContent(html, options = {}) {
132
- const allowed = origins(options.linkOrigins);
165
+ const allowed = originSet(options.linkOrigins);
166
+ const dependencies = dependencyOriginSet(options.dependencyOrigins);
133
167
  const refused = refusedSet(options);
134
168
  const keepScripts = options.keepScripts ?? false;
135
169
  const found = [];
136
170
  for (const element of elements(parse(html).children)) {
137
171
  if (refusedElement(element, refused, keepScripts)) found.push(`<${element.name}>`);
138
- found.push(...styleFindings(element, options.dataFonts ?? false));
139
- for (const [name, value] of Object.entries(element.attribs)) if (refusedAttribute(name, value, allowed, keepScripts)) found.push(`${element.name}[${name}]`);
172
+ found.push(...styleFindings(element, options, dependencies));
173
+ for (const [name, value] of Object.entries(element.attribs)) {
174
+ const reason = refusedAttribute(element, name, value, allowed, options, dependencies);
175
+ if (reason !== void 0) found.push(`${element.name}[${name}]${reason ? ` ${reason}` : ""}`);
176
+ }
140
177
  }
141
178
  return found;
142
179
  }
143
- function cleanStyleElement(element, dataFonts) {
180
+ function cleanStyleElement(element, options, dependencies) {
144
181
  if (element.name.toLowerCase() !== "style") return;
145
182
  const children = element.children.filter((candidate) => "data" in candidate);
146
183
  const child = children[0];
147
184
  if (!child || !("data" in child)) return;
148
- child.data = sanitizeCss(styleText(element), false, dataFonts).css;
185
+ child.data = dependencyCss(styleText(element), false, options, dependencies).css;
149
186
  for (const extra of children.slice(1)) if ("data" in extra) extra.data = "";
150
187
  }
151
- function cleanAttributes(element, allowed, keepScripts, dataFonts) {
152
- for (const [name, value] of Object.entries(element.attribs)) if (refusedAttribute(name, value, allowed, keepScripts)) delete element.attribs[name];
188
+ function cleanAttributes(element, allowed, options, dependencies) {
189
+ for (const [name, value] of Object.entries(element.attribs)) if (refusedAttribute(element, name, value, allowed, options, dependencies) !== void 0) delete element.attribs[name];
153
190
  else if (name.toLowerCase() === "style") {
154
- const css = sanitizeCss(value, true, dataFonts).css.trim();
191
+ const css = dependencyCss(value, true, options, dependencies).css.trim();
155
192
  if (css) element.attribs[name] = css;
156
193
  else delete element.attribs[name];
157
194
  }
158
195
  }
159
196
  function sanitize(html, options = {}) {
160
- const allowed = origins(options.linkOrigins);
197
+ const allowed = originSet(options.linkOrigins);
198
+ const dependencies = dependencyOriginSet(options.dependencyOrigins);
161
199
  const refused = refusedSet(options);
162
200
  const keepScripts = options.keepScripts ?? false;
163
201
  const document = parse(html);
164
202
  const prune = (nodes) => nodes.filter((node) => {
165
203
  if (!isTag(node)) return true;
166
204
  if (refusedElement(node, refused, keepScripts)) return false;
167
- cleanStyleElement(node, options.dataFonts ?? false);
168
- cleanAttributes(node, allowed, keepScripts, options.dataFonts ?? false);
205
+ cleanStyleElement(node, options, dependencies);
206
+ cleanAttributes(node, allowed, options, dependencies);
169
207
  node.children = prune(node.children);
170
208
  return true;
171
209
  });
@@ -173,4 +211,4 @@ function sanitize(html, options = {}) {
173
211
  return serialize(document);
174
212
  }
175
213
  //#endregion
176
- export { REFUSED_ELEMENTS, elements, findActiveContent, parse, sanitize, serialize };
214
+ export { REFUSED_ELEMENTS, elements, findActiveContent, parse, safeImageSource, sanitize, serialize };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segnavia/html",
3
- "version": "0.37.0",
3
+ "version": "0.39.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.37.0",
29
+ "@segnavia/format": "0.39.0",
30
30
  "dom-serializer": "^2.0.0",
31
31
  "domhandler": "^5.0.3",
32
32
  "htmlparser2": "^10.0.0",