@segnavia/html 0.38.0 → 0.40.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.
@@ -14,11 +14,11 @@ function contentsList(book, headings, options) {
14
14
  return element("li", attributes, [element("a", {
15
15
  href: options.crossLinks ? `segnavia:target/${heading.id}` : `#${heading.id}`,
16
16
  dir: resolveDirection(book.dir, book.lang, heading.text)
17
- }, [text(heading.text)]), element("span", {
17
+ }, [text(heading.text)]), book.contents.pageNumbers ? element("a", {
18
18
  class: "segnavia-contents-page",
19
- "data-target": heading.id,
20
- dir: resolveDirection(book.dir, book.lang, page)
21
- }, [text(page)])]);
19
+ href: `#${heading.id}`,
20
+ "data-ref": "page"
21
+ }, [text(page)]) : element("span", { class: "segnavia-contents-page" }, [])]);
22
22
  });
23
23
  return element("ol", { class: "segnavia-contents-list" }, entries);
24
24
  }
@@ -11,13 +11,20 @@ function targetLinks(sections) {
11
11
  tabindex: "-1"
12
12
  }, [text("​")])));
13
13
  }
14
- function notesGalley(paragraphs) {
14
+ function notesGalley(series, paragraphs) {
15
15
  const notes = paragraphs.map((paragraph) => element("div", { "data-segnavia-note": paragraph.attribs.id ?? "" }, [paragraph]));
16
16
  return element("aside", {
17
17
  "data-segnavia-notes-galley": "",
18
+ "data-segnavia-note-series": series,
18
19
  "aria-hidden": "true"
19
20
  }, notes);
20
21
  }
22
+ function notesGalleys(series, paragraphs) {
23
+ return series.flatMap((item) => {
24
+ const held = paragraphs.get(item.name);
25
+ return item.placement === "page" && held?.length ? [notesGalley(item.name, held)] : [];
26
+ });
27
+ }
21
28
  function withBlankPages(sections, blankBefore) {
22
29
  const counts = /* @__PURE__ */ new Map();
23
30
  for (const id of blankBefore ?? []) counts.set(id, (counts.get(id) ?? 0) + 1);
@@ -27,4 +34,4 @@ function withBlankPages(sections, blankBefore) {
27
34
  })), section.node]);
28
35
  }
29
36
  //#endregion
30
- export { notesGalley, targetLinks, withBlankPages };
37
+ export { notesGalleys, targetLinks, withBlankPages };
@@ -1,10 +1,11 @@
1
1
  import { elements, parse, serialize } from "../sanitize.js";
2
2
  import { element, linkChildren, text } from "./dom.js";
3
3
  import { addContents } from "./book-contents.js";
4
- import { notesGalley, targetLinks, withBlankPages } from "./book-document-nodes.js";
4
+ import { notesGalleys, targetLinks, withBlankPages } from "./book-document-nodes.js";
5
5
  import { bookDocumentCss, bookFlowPageGeometry, markBlockPositions, resolveBookBuild } from "./book-flow.js";
6
6
  import { authoredSection, collectHeadings, designedSection, flowAttribs, markPlacementIssues, sectionNode, templateHolders, templateRegionIssues, templatedSection, templatedSectionIssues } from "./book-held.js";
7
- import { duplicateIdIssues, reservedIdIssues } from "./book-issues.js";
7
+ import { pageReferenceNodes, pageReferenceTarget, pageReferenceTargets } from "./book-page-reference.js";
8
+ import { duplicateIdIssues, pageReferenceIssues, reservedIdIssues } from "./book-issues.js";
8
9
  import { addPart, attachPendingPartHeadings, partNames } from "./book-part-build.js";
9
10
  import { noteState, transformNotes } from "./notes.js";
10
11
  import { bookPrintPart } from "./types.js";
@@ -159,6 +160,7 @@ function addFrontMatter(book, build) {
159
160
  appendTemplated(entry, `segnavia-templated-${build.templatedNumber}`, "front", book, build, sectionEntry);
160
161
  continue;
161
162
  }
163
+ if (!("html" in entry)) continue;
162
164
  frontNumber += 1;
163
165
  const section = authoredSection(entry, `segnavia-front-${frontNumber}`, build.build);
164
166
  build.sections.push(section);
@@ -238,6 +240,17 @@ function insertCollectedSections(book, build) {
238
240
  build.sections.splice(index < 0 ? build.sections.length : index, 0, notesSection(book, notes, build.build, collectedSectionId(notes), notes.series.title));
239
241
  }
240
242
  }
243
+ function writePageReferenceNumbers(book, roots, options) {
244
+ for (const node of pageReferenceNodes(roots)) {
245
+ const id = pageReferenceTarget(node.attribs);
246
+ if (!id) continue;
247
+ const page = options.pageNumbers?.[id] ?? "";
248
+ node.children = [text(page)];
249
+ node.attribs.dir = resolveDirection(book.dir, book.lang, page);
250
+ if (options.crossLinks) node.attribs.href = `segnavia:target/${id}`;
251
+ linkChildren(node);
252
+ }
253
+ }
241
254
  function bookDocument(book, options = {}) {
242
255
  const { build: requestedBuild, issue: buildIssue } = resolveBookBuild(book, options.build);
243
256
  const build = {
@@ -259,6 +272,18 @@ function bookDocument(book, options = {}) {
259
272
  const { notes, sections, headings, issues } = build;
260
273
  insertCollectedSections(book, build);
261
274
  if (contents) addContents(book, contents, headings, options);
275
+ const noteGalleys = notesGalleys(book.notes, notes.pageParagraphs);
276
+ const scopes = [...sections.map((section) => ({
277
+ owner: `${section.info.title ?? section.info.id} (${section.info.id})`,
278
+ roots: [section.node]
279
+ })), ...noteGalleys.map((galley) => ({
280
+ owner: `page-placed note series ${galley.attribs["data-segnavia-note-series"] ?? ""}`,
281
+ roots: [galley]
282
+ }))];
283
+ const referenceIssues = pageReferenceIssues(sections, scopes);
284
+ const referenceRoots = scopes.flatMap((scope) => scope.roots);
285
+ const referenceTargets = pageReferenceTargets(referenceRoots);
286
+ writePageReferenceNumbers(book, referenceRoots, options);
262
287
  if (requestedBuild !== "print") markBlockPositions(sections);
263
288
  const visible = sections.filter((section) => options.part === void 0 || bookPrintPart(section.info.division) === options.part);
264
289
  const document = parse("");
@@ -268,7 +293,7 @@ function bookDocument(book, options = {}) {
268
293
  element("div", { "data-segnavia-galleys": "" }, [
269
294
  ...templateHolders(book.templates),
270
295
  ...visible.map((section) => section.node),
271
- ...notes.pageParagraphs.length > 0 ? [notesGalley(notes.pageParagraphs)] : []
296
+ ...noteGalleys
272
297
  ])
273
298
  ] : [
274
299
  element("style", {}, [text(bookDocumentCss(book, requestedBuild))]),
@@ -280,6 +305,7 @@ function bookDocument(book, options = {}) {
280
305
  html: serialize(document),
281
306
  sections: sections.map((section) => section.info),
282
307
  headings,
308
+ pageReferenceTargets: referenceTargets,
283
309
  issues: [
284
310
  ...buildIssue ? [buildIssue] : [],
285
311
  ...noteMarkerIssues(book.notes, notes.used),
@@ -287,7 +313,8 @@ function bookDocument(book, options = {}) {
287
313
  ...markPlacementIssues(book.parentPages),
288
314
  ...templateRegionIssues(book.templates),
289
315
  ...issues,
290
- ...duplicateIdIssues(sections, headings)
316
+ ...duplicateIdIssues(sections, headings),
317
+ ...referenceIssues
291
318
  ]
292
319
  };
293
320
  }
@@ -6,7 +6,7 @@ const BOOK_COLUMN_MIN = 2;
6
6
  const BOOK_COLUMN_MAX = 4;
7
7
  const CONTENTS_INDENTS = Array.from({ length: 5 }, (_, index) => index + 2).map((level) => `:where(.segnavia-contents-entry[data-level="${level}"]){padding-inline-start:${level - 1}em}`).join("");
8
8
  const DEEP_CONTENTS_INDENTS = Array.from({ length: MAX_PART_DEPTH }, (_, index) => index + 7).map((level) => `:where(.segnavia-contents-entry[data-level="${level}"]){padding-inline-start:${level - 1}em}`).join("");
9
- const CONTENTS_PREFIX = `:where(.segnavia-contents-list){list-style:none;padding:0;margin:0}:where(.segnavia-contents-entry){display:flex}:where(.segnavia-contents-page){margin-inline-start:auto}${CONTENTS_INDENTS}`;
9
+ const CONTENTS_PREFIX = `:where(.segnavia-contents-list){list-style:none;padding:0;margin:0}:where(.segnavia-contents-entry){display:flex}:where(.segnavia-contents-page){margin-inline-start:auto}:where([data-ref="page"]){unicode-bidi:isolate}${CONTENTS_INDENTS}`;
10
10
  const CONTENTS_SUFFIX = ":where(.segnavia-targets){position:absolute;inline-size:1px;block-size:1px;overflow:hidden;clip-path:inset(50%)}";
11
11
  const GALLEY_SUFFIX = ":where([data-segnavia-galleys]){position:relative}:where([data-segnavia-flow]){orphans:inherit;widows:inherit}:where([data-segnavia-kind=\"fixed\"],[data-segnavia-kind=\"templated\"],[data-segnavia-template]){display:none}:where([data-segnavia-notes-galley]){border-block-start:1px solid;padding-block-start:8px}:where([data-segnavia-note]){break-inside:avoid}:where([data-segnavia-galleys] img,[data-segnavia-window] img,[data-segnavia-page-note] img){display:block;max-block-size:var(--segnavia-text-height);max-inline-size:100%;block-size:auto;inline-size:auto}";
12
12
  function hasParts(book) {
@@ -1,24 +1,42 @@
1
1
  import { elements } from "../sanitize.js";
2
+ import { pageReferenceNodes, pageReferenceTarget } from "./book-page-reference.js";
2
3
  //#region src/book/book-issues.ts
4
+ function sectionIds(section) {
5
+ return [section.info.id, ...elements(section.node.children).flatMap((node) => node.attribs.id ? [node.attribs.id] : [])];
6
+ }
7
+ function sectionName(section) {
8
+ return `${section.title ?? section.id} (${section.id})`;
9
+ }
10
+ function ownersById(sections) {
11
+ const occurrences = /* @__PURE__ */ new Map();
12
+ for (const section of sections) for (const id of sectionIds(section)) occurrences.set(id, [...occurrences.get(id) ?? [], section.info]);
13
+ return occurrences;
14
+ }
3
15
  function duplicateIdIssues(sections, headings) {
4
16
  const listed = new Set(headings.map((heading) => heading.id));
5
- const occurrences = /* @__PURE__ */ new Map();
6
- for (const section of sections) {
7
- const ids = [section.info.id, ...elements(section.node.children).flatMap((node) => node.attribs.id ? [node.attribs.id] : [])];
8
- for (const id of ids) occurrences.set(id, [...occurrences.get(id) ?? [], section.info]);
9
- }
10
- return [...occurrences].flatMap(([id, owners]) => {
17
+ return [...ownersById(sections)].flatMap(([id, owners]) => {
11
18
  if (!listed.has(id) || owners.length < 2) return [];
12
- const named = owners.slice(0, 2).map((owner) => `${owner.title ?? owner.id} (${owner.id})`);
19
+ const named = owners.slice(0, 2).map(sectionName);
13
20
  return [`destination id ${id} is used in both ${named[0]} and ${named[1]}; listed heading and generated destination ids must be unique`];
14
21
  });
15
22
  }
23
+ function pageReferenceIssues(sections, scopes) {
24
+ const targets = ownersById(sections);
25
+ return scopes.flatMap((scope) => pageReferenceNodes(scope.roots).flatMap((node) => {
26
+ const id = pageReferenceTarget(node.attribs);
27
+ if (!id) return [];
28
+ const owners = targets.get(id) ?? [];
29
+ if (owners.length === 0) return [`page reference to ${id} in ${scope.owner} has no target; add the id or remove the reference`];
30
+ if (owners.length === 1) return [];
31
+ return [`page reference target ${id} is used in ${owners.length} sections: ${owners.map(sectionName).join(", ")}; make the target id unique to clear this refusal`];
32
+ }));
33
+ }
16
34
  function reservedIdIssues(section) {
17
35
  return elements(section.node.children).flatMap((node) => {
18
36
  const id = node.attribs.id;
19
37
  if (!id?.startsWith("segnavia-")) return [];
20
- return [`authored id ${id} in ${section.info.title ?? section.info.id} (${section.info.id}) uses the reserved segnavia- prefix`];
38
+ return [`authored id ${id} in ${sectionName(section.info)} uses the reserved segnavia- prefix`];
21
39
  });
22
40
  }
23
41
  //#endregion
24
- export { duplicateIdIssues, reservedIdIssues };
42
+ export { duplicateIdIssues, pageReferenceIssues, reservedIdIssues };
@@ -0,0 +1,7 @@
1
+ import { Element } from "domhandler";
2
+ //#region src/book/book-page-reference.d.ts
3
+ export declare const PAGE_REFERENCE_ATTRIBUTE = "data-ref";
4
+ export declare const PAGE_REFERENCE_VALUE = "page";
5
+ export declare const PAGE_REFERENCE_SELECTOR = "a[data-ref=\"page\"]";
6
+ export declare function pageReferenceTarget(attribs: Record<string, string>): string | undefined;
7
+ //#endregion
@@ -0,0 +1,19 @@
1
+ import { elements } from "../sanitize.js";
2
+ //#region src/book/book-page-reference.ts
3
+ const PAGE_REFERENCE_ATTRIBUTE = "data-ref";
4
+ const PAGE_REFERENCE_VALUE = "page";
5
+ const PAGE_REFERENCE_SELECTOR = "a[data-ref=\"page\"]";
6
+ function pageReferenceTarget(attribs) {
7
+ if (attribs["data-ref"] !== "page") return void 0;
8
+ const href = attribs.href;
9
+ if (href?.startsWith("#") && href.length > 1) return href.slice(1);
10
+ return href?.startsWith("segnavia:target/") && href.length > 16 ? href.slice(16) : void 0;
11
+ }
12
+ function pageReferenceNodes(roots) {
13
+ return roots.flatMap((root) => [root, ...elements(root.children)]).filter((node) => node.name === "a" && pageReferenceTarget(node.attribs) !== void 0);
14
+ }
15
+ function pageReferenceTargets(roots) {
16
+ return [...new Set(pageReferenceNodes(roots).flatMap((node) => pageReferenceTarget(node.attribs) ?? []))];
17
+ }
18
+ //#endregion
19
+ export { PAGE_REFERENCE_ATTRIBUTE, PAGE_REFERENCE_SELECTOR, PAGE_REFERENCE_VALUE, pageReferenceNodes, pageReferenceTarget, pageReferenceTargets };
@@ -35,7 +35,7 @@ function noteState() {
35
35
  used: /* @__PURE__ */ new Set(),
36
36
  counters: /* @__PURE__ */ new Map(),
37
37
  collected: /* @__PURE__ */ new Map(),
38
- pageParagraphs: [],
38
+ pageParagraphs: /* @__PURE__ */ new Map(),
39
39
  authored: /* @__PURE__ */ new Map(),
40
40
  repeatCount: 0,
41
41
  issues: []
@@ -87,8 +87,9 @@ function noteIds(series, scope, chapter, number) {
87
87
  refId: `segnavia-${series.idStem}ref${scopePart}-${number}`
88
88
  };
89
89
  }
90
- function addPageBlock(block, paragraph, blocks, series) {
91
- const existing = blocks.get(block);
90
+ function addPageBlock(block, paragraph, blocks, series, seriesOrder) {
91
+ const blockSeries = blocks.get(block) ?? /* @__PURE__ */ new Map();
92
+ const existing = blockSeries.get(series.name);
92
93
  if (existing) {
93
94
  append(existing, paragraph);
94
95
  return;
@@ -98,9 +99,16 @@ function addPageBlock(block, paragraph, blocks, series) {
98
99
  ...series.markBlockSeries ? { "data-segnavia-note-series": series.name } : {}
99
100
  };
100
101
  const notes = element("div", attributes, [paragraph]);
101
- blocks.set(block, notes);
102
- if (INSIDE_BLOCKS.has(block.name)) append(block, notes);
103
- else insertAfter(block, notes);
102
+ const index = seriesOrder.get(series.name) ?? seriesOrder.size;
103
+ const next = [...blockSeries.entries()].filter(([name]) => (seriesOrder.get(name) ?? seriesOrder.size) > index).sort(([left], [right]) => (seriesOrder.get(left) ?? seriesOrder.size) - (seriesOrder.get(right) ?? seriesOrder.size))[0]?.[1];
104
+ if (next?.prev) insertAfter(next.prev, notes);
105
+ else if (INSIDE_BLOCKS.has(block.name)) append(block, notes);
106
+ else {
107
+ const previous = [...blockSeries.entries()].filter(([name]) => (seriesOrder.get(name) ?? seriesOrder.size) < index).sort(([left], [right]) => (seriesOrder.get(right) ?? seriesOrder.size) - (seriesOrder.get(left) ?? seriesOrder.size))[0]?.[1];
108
+ insertAfter(previous ?? block, notes);
109
+ }
110
+ blockSeries.set(series.name, notes);
111
+ blocks.set(block, blockSeries);
104
112
  }
105
113
  function collectedNotes(series, context, state) {
106
114
  const key = placementKey(series, context);
@@ -119,7 +127,7 @@ function collectedNotes(series, context, state) {
119
127
  state.collected.set(mapKey, collected);
120
128
  return collected;
121
129
  }
122
- function transformAuthoredNote(note, series, context, state, blocks, galley) {
130
+ function transformAuthoredNote(note, series, context, state, blocks, seriesOrder, galley) {
123
131
  const block = nearestBlock(note);
124
132
  if (!block) return;
125
133
  const { number, scope } = nextNumber(series, context, state);
@@ -130,8 +138,11 @@ function transformAuthoredNote(note, series, context, state, blocks, galley) {
130
138
  replace(note, element("sup", { class: "segnavia-note-ref" }, [markerLink(refId, noteId, marker)]));
131
139
  const paragraph = noteParagraph(series.bodyClass, noteId, refId, marker, content);
132
140
  if (series.placement === "page") {
133
- if (galley) state.pageParagraphs.push(paragraph);
134
- else addPageBlock(block, paragraph, blocks, series);
141
+ if (galley) {
142
+ const paragraphs = state.pageParagraphs.get(series.name) ?? [];
143
+ paragraphs.push(paragraph);
144
+ state.pageParagraphs.set(series.name, paragraphs);
145
+ } else addPageBlock(block, paragraph, blocks, series, seriesOrder);
135
146
  } else collectedNotes(series, context, state).paragraphs.push(paragraph);
136
147
  if (authoredId) state.authored.set(authoredId, {
137
148
  noteId,
@@ -156,6 +167,7 @@ function transformRepeat(node, state) {
156
167
  function transformNotes(root, book, context, state, galley = false) {
157
168
  const blocks = /* @__PURE__ */ new Map();
158
169
  const byName = new Map(book.notes.map((series) => [series.name, series]));
170
+ const seriesOrder = new Map(book.notes.map((series, index) => [series.name, index]));
159
171
  const nodes = elements(root.children).filter((node) => node.name === "span" && node.attribs["data-note"] !== void 0 || node.name === "a" && node.attribs["data-note-ref"] !== void 0);
160
172
  for (const node of nodes) {
161
173
  if (node.name === "a") {
@@ -168,7 +180,7 @@ function transformNotes(root, book, context, state, galley = false) {
168
180
  state.issues.push(`data-note names unknown note series ${name}; declare the series in notes or use a declared series name`);
169
181
  continue;
170
182
  }
171
- transformAuthoredNote(node, series, context, state, blocks, galley);
183
+ transformAuthoredNote(node, series, context, state, blocks, seriesOrder, galley);
172
184
  }
173
185
  }
174
186
  //#endregion
@@ -2,7 +2,13 @@
2
2
  function paginateNotes(payload) {
3
3
  const notesById = new Map(payload.notes.map((note) => [note.id, note]));
4
4
  const notesGap = payload.geometry.notesGap;
5
+ const noteSeries = payload.geometry.noteSeries;
5
6
  const textHeight = payload.geometry.textHeight;
7
+ const seriesOrder = new Map(noteSeries?.map((series, index) => [series.name, index]));
8
+ function ordered(fragments) {
9
+ if (!noteSeries) return fragments;
10
+ return fragments.slice().sort((left, right) => (seriesOrder.get(left.series) ?? noteSeries.length) - (seriesOrder.get(right.series) ?? noteSeries.length));
11
+ }
6
12
  function noteEnd(id) {
7
13
  return notesById.get(id)?.lines.length ?? 0;
8
14
  }
@@ -15,8 +21,13 @@ function paginateNotes(payload) {
15
21
  function notesSpace(fragments) {
16
22
  if (fragments.length === 0) return 0;
17
23
  let height = 0;
18
- for (const fragment of fragments) height += fragmentHeight(fragment);
19
- return notesGap + height;
24
+ let previous;
25
+ for (const fragment of noteSeries ? ordered(fragments) : fragments) {
26
+ if (fragment.series !== previous) height += noteSeries?.find(({ name }) => name === fragment.series)?.gap ?? notesGap;
27
+ height += fragmentHeight(fragment);
28
+ previous = fragment.series;
29
+ }
30
+ return height;
20
31
  }
21
32
  function measure(placed, fragments) {
22
33
  if (payload.op === "continue" || payload.op === "allocate" || payload.op === "recompute") return payload.used(placed, fragments);
@@ -25,13 +36,14 @@ function paginateNotes(payload) {
25
36
  function makeFragment(id, start, end) {
26
37
  return {
27
38
  id,
39
+ series: notesById.get(id)?.series ?? "",
28
40
  whole: start === 0 && end === noteEnd(id),
29
41
  start,
30
42
  end
31
43
  };
32
44
  }
33
45
  function leftoverFrom(fragments) {
34
- return fragments.flatMap((fragment) => fragment.end < noteEnd(fragment.id) ? [makeFragment(fragment.id, fragment.end, noteEnd(fragment.id))] : []);
46
+ return ordered(fragments.flatMap((fragment) => fragment.end < noteEnd(fragment.id) ? [makeFragment(fragment.id, fragment.end, noteEnd(fragment.id))] : []));
35
47
  }
36
48
  function takeContinued(continued) {
37
49
  if (continued.length === 0) return {
@@ -41,7 +53,7 @@ function paginateNotes(payload) {
41
53
  const fragments = [];
42
54
  const leftover = [];
43
55
  let pending = true;
44
- for (const item of continued) {
56
+ for (const item of ordered(continued)) {
45
57
  const total = noteEnd(item.id);
46
58
  if (!pending) {
47
59
  leftover.push(item);
@@ -59,8 +71,8 @@ function paginateNotes(payload) {
59
71
  pending = false;
60
72
  }
61
73
  return {
62
- fragments,
63
- leftover
74
+ fragments: ordered(fragments),
75
+ leftover: ordered(leftover)
64
76
  };
65
77
  }
66
78
  function fitNote(trial, fragments, id) {
@@ -84,7 +96,7 @@ function paginateNotes(payload) {
84
96
  if (!fragment) return void 0;
85
97
  fragments.push(fragment);
86
98
  }
87
- return fragments;
99
+ return ordered(fragments);
88
100
  }
89
101
  function notesFor(placed, seed) {
90
102
  let fragments = seed;
@@ -53,13 +53,19 @@ export type PaginateFlow = (PaginateFlowBase & {
53
53
  });
54
54
  export interface PaginateNote {
55
55
  id: string;
56
+ series: string;
56
57
  lines: PaginateLine[];
57
58
  }
59
+ export interface PaginateNoteSeriesGeometry {
60
+ name: string;
61
+ gap: number;
62
+ }
58
63
  export interface PaginateBookInput {
59
64
  flows: PaginateFlow[];
60
65
  notes: PaginateNote[];
61
66
  geometry: {
62
67
  notesGap: number;
68
+ noteSeries?: PaginateNoteSeriesGeometry[];
63
69
  };
64
70
  parents: Record<string, TextArea>;
65
71
  parentNames: typeof BOOK_PARENT_NAMES;
@@ -81,6 +87,7 @@ export interface PaginateWindow {
81
87
  }
82
88
  export interface PaginatePageNote {
83
89
  id: string;
90
+ series: string;
84
91
  whole: boolean;
85
92
  start: number;
86
93
  end: number;
@@ -148,6 +155,7 @@ interface PaginateNotesBase {
148
155
  geometry: {
149
156
  textHeight: number;
150
157
  notesGap: number;
158
+ noteSeries?: PaginateNoteSeriesGeometry[];
151
159
  };
152
160
  }
153
161
  export type PaginateNotesOp = (PaginateNotesBase & {
@@ -28,6 +28,7 @@ export interface BookDocument {
28
28
  html: string;
29
29
  sections: BookSectionInfo[];
30
30
  headings: BookHeading[];
31
+ pageReferenceTargets: string[];
31
32
  issues: string[];
32
33
  }
33
34
  //#endregion
package/dist/font-css.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { bytesToBase64 } from "./base64.js";
2
2
  import { hasControlChar } from "./control-char.js";
3
3
  import { UNICODE_RANGE_CLEARANCE, parseUnicodeRange } from "./unicode-range.js";
4
- import { APP_FONT_NAME, FONT_FORMATS } from "@segnavia/format";
4
+ import { APP_FONT_NAME, FONT_FORMATS, FontFaceRefusedError, resolveFontFace } from "@segnavia/format";
5
5
  //#region src/font-css.ts
6
6
  var FontCssRefusedError = class extends Error {
7
7
  constructor(message) {
@@ -59,7 +59,18 @@ function validatedFace(font) {
59
59
  return font;
60
60
  }
61
61
  function fontFaceCss(font) {
62
- const face = validatedFace(font);
62
+ const validated = validatedFace(font);
63
+ let resolved;
64
+ try {
65
+ resolved = resolveFontFace(validated.data, validated.format, validated.postscriptName);
66
+ } catch (error) {
67
+ if (error instanceof FontFaceRefusedError) throw new FontCssRefusedError(error.message);
68
+ throw error;
69
+ }
70
+ const face = {
71
+ ...validated,
72
+ ...resolved
73
+ };
63
74
  return `@font-face{font-family:${quoteFamily(face.family)};src:url(data:font/${mimeType(face.format)};base64,${bytesToBase64(face.data)}) format(${JSON.stringify(face.format)});font-weight:${face.weight ?? "normal"};font-style:${face.style ?? "normal"};${unicodeRangeDeclaration(face)}font-display:block}`;
64
75
  }
65
76
  function customProperty(name, font) {
@@ -1,11 +1,7 @@
1
+ import { classifyFont } from "@segnavia/format";
1
2
  //#region src/font-format.ts
2
3
  function fontFormat(data) {
3
- if (data.length < 4) return;
4
- const tag = String.fromCharCode(data[0] ?? 0, data[1] ?? 0, data[2] ?? 0, data[3] ?? 0);
5
- if (tag === "wOF2") return "woff2";
6
- if (tag === "wOFF") return "woff";
7
- if (tag === "OTTO") return "opentype";
8
- if (tag === "true" || data[0] === 0 && data[1] === 1 && data[2] === 0 && data[3] === 0) return "truetype";
4
+ return classifyFont(data);
9
5
  }
10
6
  //#endregion
11
7
  export { fontFormat };
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { base64ToBytes, bytesToBase64 } from "./base64.js";
2
- import { REFUSED_ELEMENTS, SanitizeOptions, findActiveContent, sanitize } from "./sanitize.js";
2
+ import { REFUSED_ELEMENTS, SanitizeOptions, UrlPolicy, findActiveContent, safeImageSource, safeMarkdownUrl, sanitize } from "./sanitize.js";
3
3
  import { BINDINGS, FillOptions, Filled, bindingNames, bindingRoot, fill, hasBindings } from "./bindings.js";
4
4
  import { BookBuild, BookDocument, BookDocumentOptions, BookHeading, BookPrintPart, BookSectionInfo, bookPrintPart } from "./book/types.js";
5
5
  import { BOOK_COLUMN_MAX, BOOK_COLUMN_MIN, BOOK_FLOW_COLUMN_MIN_PX, BookPositionKey, parseBookPositionKey } from "./book/book-flow.js";
6
6
  import { bookDocument } from "./book/book-document.js";
7
+ import { PAGE_REFERENCE_ATTRIBUTE, PAGE_REFERENCE_SELECTOR, PAGE_REFERENCE_VALUE, pageReferenceTarget } from "./book/book-page-reference.js";
7
8
  import { bookView, bookViewFromHtml } from "./book/book-view.js";
8
- import { PaginateBlock, PaginateBookInput, PaginateBookPage, PaginateBookRefusal, PaginateBookResult, PaginateFlow, PaginateFlowParents, PaginateLine, PaginateMark, PaginateNote, PaginateNoteRef, PaginateOpens, PaginatePageMarks, PaginatePageNote, PaginatePageRole, PaginateSequence, PaginateWindow } from "./book/paginate-types.js";
9
+ import { PaginateBlock, PaginateBookInput, PaginateBookPage, PaginateBookRefusal, PaginateBookResult, PaginateFlow, PaginateFlowParents, PaginateLine, PaginateMark, PaginateNote, PaginateNoteRef, PaginateNoteSeriesGeometry, PaginateOpens, PaginatePageMarks, PaginatePageNote, PaginatePageRole, PaginateSequence, PaginateWindow } from "./book/paginate-types.js";
9
10
  import { BookFurnitureValues, embedFunction, fillBookFurniture, furnitureScript } from "./book/furniture.js";
10
11
  import { BookPageFacts, BookPrintedPagePlan, BookPrintedPageRole, BookSectionParents, PrintedPagePlan, PrintedPageSide, planBookPages, planPrintedPages, printedPageAt, selectBookParent } from "./printed-pages.js";
11
12
  import { PAGINATE_FUNCTIONS, paginateBook, paginateScript } from "./book/paginate-book.js";
@@ -15,7 +16,9 @@ import { bookStylesheet, sanitizeBookCss } from "./book-css.js";
15
16
  import { FontCssRefusedError, fontCss, fontFaceCss } from "./font-css.js";
16
17
  import { fontFormat } from "./font-format.js";
17
18
  import { fontReferenceRefusals } from "./font-references.js";
19
+ import { AlertKind, AlertLabels, BodyHtmlOptions, HtmlPage, MarkdownHeading, alertLabels, bodyHtml, isDiagramFence, isGithubAlertBlock, markdownOutline, resolvePageHtml } from "./markdown.js";
18
20
  import { ExpandMotifsOptions, ExpandedMotifs, MOTIF_DEPTH_LIMIT, MotifRefusedError, PlacedMotif, documentLanguage, expandMotifs, hasHostMotifPlacement, hasMotifPlacement, libraryMotifPlacementRefusal, motifPlacementRefusals, renderMotifPlacements } from "./motifs.js";
21
+ import { MediaAnswer, PLACEMENT_ATTRIBUTES, PlacementPlan, PlacementPlanEntry, PlacementRefusal, PlacementReport, PlacementsOptions, ResolvePlacement, ResolvedPlacement, classifyMediaAnswer, placementCropEdges, placementPlaceholderSource, placementPlan, resolvePlacements } from "./placements.js";
19
22
  import { SlotFillEntry, SlotFillRefusal, SlotFillsConflictError, SlotFillsRefusedError, SlotPrintOptions, assertSingleSlotCopy, assertSlotPrintOptions, collectSlotPrintCopies, isSlotCopyRefusal, slotFillCopyId, slotFillsConflict } from "./slot-print.js";
20
23
  import { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal } from "./slot-policy.js";
21
24
  import { MAX_SLOT_DECLARATIONS, PreparedSlots, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal } from "./slots.js";
@@ -25,4 +28,4 @@ export type HtmlKind = 'plain' | 'live-data' | 'scripted' | 'refused';
25
28
  export declare function classify(html: string, prefix: string): HtmlKind;
26
29
  export declare function searchText(html: string): string;
27
30
  //#endregion
28
- export { ABSOLUTE_PX, ABSOLUTE_UNITS, BINDINGS, BOOK_COLUMN_MAX, BOOK_COLUMN_MIN, BOOK_FLOW_COLUMN_MIN_PX, type BookBuild, type BookDocument, type BookDocumentOptions, type BookFurnitureValues, type BookHeading, BookPageFacts, type BookPositionKey, type BookPrintPart, BookPrintedPagePlan, BookPrintedPageRole, type BookSectionInfo, type BookSectionParents, type ExpandMotifsOptions, type ExpandedMotifs, type FillOptions, type Filled, FontCssRefusedError, MAX_SLOT_DECLARATIONS, MOTIF_DEPTH_LIMIT, MotifRefusedError, PAGINATE_FUNCTIONS, type PaginateBlock, type PaginateBookInput, type PaginateBookPage, type PaginateBookRefusal, type PaginateBookResult, type PaginateFlow, type PaginateFlowParents, type PaginateLine, type PaginateMark, type PaginateNote, type PaginateNoteRef, type PaginateOpens, type PaginatePageMarks, type PaginatePageNote, type PaginatePageRole, type PaginateSequence, type PaginateTarget, type PaginateTargetPage, type PaginateWindow, type PlacedMotif, type PreparedSlots, PrintedPagePlan, PrintedPageSide, REFUSED_ELEMENTS, RequiredSlotsRefusedError, type SanitizeOptions, type SlotFillEntry, type SlotFillRefusal, SlotFillRefusedError, SlotFillsConflictError, SlotFillsRefusedError, type SlotPrintOptions, UNICODE_RANGE_CLEARANCE, type UnicodeRangeParse, UnmeasuredSlotsRefusedError, assertSingleSlotCopy, assertSlotPrintOptions, base64ToBytes, bindingNames, bindingRoot, bookDocument, bookPrintPart, bookStylesheet, bookView, bookViewFromHtml, bytesToBase64, 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, paginateBook, paginateScript, parseBookPositionKey, parseCssLength, parseUnicodeRange, planBookPages, planPrintedPages, prepareSlotCollection, prepareSlots, printedPageAt, renderMotifPlacements, requiredSlotRefusal, sanitize, sanitizeBookCss, selectBookParent, slotFillCopyId, slotFillRefusal, slotFillsConflict, unmeasuredSlotRefusal, windowPageIndex };
31
+ export { ABSOLUTE_PX, ABSOLUTE_UNITS, type AlertKind, type AlertLabels, BINDINGS, BOOK_COLUMN_MAX, BOOK_COLUMN_MIN, BOOK_FLOW_COLUMN_MIN_PX, type BodyHtmlOptions, type BookBuild, type BookDocument, type BookDocumentOptions, type BookFurnitureValues, type BookHeading, BookPageFacts, type BookPositionKey, type BookPrintPart, BookPrintedPagePlan, BookPrintedPageRole, type BookSectionInfo, type BookSectionParents, type ExpandMotifsOptions, type ExpandedMotifs, type FillOptions, type Filled, FontCssRefusedError, type HtmlPage, MAX_SLOT_DECLARATIONS, MOTIF_DEPTH_LIMIT, type MarkdownHeading, type MediaAnswer, MotifRefusedError, PAGE_REFERENCE_ATTRIBUTE, PAGE_REFERENCE_SELECTOR, PAGE_REFERENCE_VALUE, PAGINATE_FUNCTIONS, PLACEMENT_ATTRIBUTES, type PaginateBlock, type PaginateBookInput, type PaginateBookPage, type PaginateBookRefusal, type PaginateBookResult, type PaginateFlow, type PaginateFlowParents, type PaginateLine, type PaginateMark, type PaginateNote, type PaginateNoteRef, type PaginateNoteSeriesGeometry, type PaginateOpens, type PaginatePageMarks, type PaginatePageNote, type PaginatePageRole, type PaginateSequence, type PaginateTarget, type PaginateTargetPage, type PaginateWindow, type PlacedMotif, type PlacementPlan, type PlacementPlanEntry, type PlacementRefusal, type PlacementReport, type PlacementsOptions, type PreparedSlots, PrintedPagePlan, PrintedPageSide, REFUSED_ELEMENTS, RequiredSlotsRefusedError, type ResolvePlacement, type ResolvedPlacement, type SanitizeOptions, type SlotFillEntry, type SlotFillRefusal, SlotFillRefusedError, SlotFillsConflictError, SlotFillsRefusedError, type SlotPrintOptions, UNICODE_RANGE_CLEARANCE, type UnicodeRangeParse, UnmeasuredSlotsRefusedError, type UrlPolicy, alertLabels, assertSingleSlotCopy, assertSlotPrintOptions, base64ToBytes, bindingNames, bindingRoot, bodyHtml, bookDocument, bookPrintPart, bookStylesheet, bookView, bookViewFromHtml, bytesToBase64, classifyMediaAnswer, collectSlotPrintCopies, documentLanguage, embedFunction, expandMotifs, fill, fillBookFurniture, findActiveContent, fitRegionSize, fontCss, fontFaceCss, fontFormat, fontReferenceRefusals, furnitureScript, hasBindings, hasHostMotifPlacement, hasMotifPlacement, hasSlotWork, heldPageNumber, isDiagramFence, isGithubAlertBlock, isSlotCopyRefusal, lengthPx, libraryMotifPlacementRefusal, markdownOutline, mergeSlotFitResults, motifPlacementRefusals, openerPageIndex, pageIndexForTarget, pageReferenceTarget, paginateBook, paginateScript, parseBookPositionKey, parseCssLength, parseUnicodeRange, placementCropEdges, placementPlaceholderSource, placementPlan, planBookPages, planPrintedPages, prepareSlotCollection, prepareSlots, printedPageAt, renderMotifPlacements, requiredSlotRefusal, resolvePageHtml, resolvePlacements, safeImageSource, safeMarkdownUrl, sanitize, sanitizeBookCss, selectBookParent, slotFillCopyId, slotFillRefusal, slotFillsConflict, unmeasuredSlotRefusal, windowPageIndex };
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- import { REFUSED_ELEMENTS, elements, findActiveContent, parse, sanitize } from "./sanitize.js";
1
+ import { REFUSED_ELEMENTS, elements, findActiveContent, parse, safeImageSource, safeMarkdownUrl, 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
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";
6
7
  import { bookPrintPart } from "./book/types.js";
7
8
  import { bookDocument } from "./book/book-document.js";
8
9
  import { bookView, bookViewFromHtml } from "./book/book-view.js";
@@ -15,7 +16,9 @@ import { UNICODE_RANGE_CLEARANCE, parseUnicodeRange } from "./unicode-range.js";
15
16
  import { FontCssRefusedError, fontCss, fontFaceCss } from "./font-css.js";
16
17
  import { fontFormat } from "./font-format.js";
17
18
  import { fontReferenceRefusals } from "./font-references.js";
19
+ import { alertLabels, bodyHtml, isDiagramFence, isGithubAlertBlock, markdownOutline, resolvePageHtml } from "./markdown.js";
18
20
  import { MOTIF_DEPTH_LIMIT, MotifRefusedError, documentLanguage, expandMotifs, hasHostMotifPlacement, hasMotifPlacement, libraryMotifPlacementRefusal, motifPlacementRefusals, renderMotifPlacements } from "./motifs.js";
21
+ import { PLACEMENT_ATTRIBUTES, classifyMediaAnswer, placementCropEdges, placementPlaceholderSource, placementPlan, resolvePlacements } from "./placements.js";
19
22
  import { RequiredSlotsRefusedError, UnmeasuredSlotsRefusedError, mergeSlotFitResults, requiredSlotRefusal, unmeasuredSlotRefusal } from "./slot-policy.js";
20
23
  import { MAX_SLOT_DECLARATIONS, SlotFillRefusedError, hasSlotWork, prepareSlotCollection, prepareSlots, slotFillRefusal } from "./slots.js";
21
24
  import { SlotFillsConflictError, SlotFillsRefusedError, assertSingleSlotCopy, assertSlotPrintOptions, collectSlotPrintCopies, isSlotCopyRefusal, slotFillCopyId, slotFillsConflict } from "./slot-print.js";
@@ -31,4 +34,4 @@ function searchText(html) {
31
34
  return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
32
35
  }
33
36
  //#endregion
34
- 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, 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, fitRegionSize, fontCss, fontFaceCss, fontFormat, fontReferenceRefusals, furnitureScript, hasBindings, hasHostMotifPlacement, hasMotifPlacement, hasSlotWork, heldPageNumber, isSlotCopyRefusal, lengthPx, libraryMotifPlacementRefusal, mergeSlotFitResults, motifPlacementRefusals, openerPageIndex, pageIndexForTarget, paginateBook, paginateScript, parseBookPositionKey, parseCssLength, parseUnicodeRange, planBookPages, planPrintedPages, prepareSlotCollection, prepareSlots, printedPageAt, renderMotifPlacements, requiredSlotRefusal, sanitize, sanitizeBookCss, searchText, selectBookParent, slotFillCopyId, slotFillRefusal, slotFillsConflict, unmeasuredSlotRefusal, windowPageIndex };
37
+ 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, alertLabels, assertSingleSlotCopy, assertSlotPrintOptions, base64ToBytes, bindingNames, bindingRoot, bodyHtml, 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, isDiagramFence, isGithubAlertBlock, isSlotCopyRefusal, lengthPx, libraryMotifPlacementRefusal, markdownOutline, mergeSlotFitResults, motifPlacementRefusals, openerPageIndex, pageIndexForTarget, pageReferenceTarget, paginateBook, paginateScript, parseBookPositionKey, parseCssLength, parseUnicodeRange, placementCropEdges, placementPlaceholderSource, placementPlan, planBookPages, planPrintedPages, prepareSlotCollection, prepareSlots, printedPageAt, renderMotifPlacements, requiredSlotRefusal, resolvePageHtml, resolvePlacements, safeImageSource, safeMarkdownUrl, sanitize, sanitizeBookCss, searchText, selectBookParent, slotFillCopyId, slotFillRefusal, slotFillsConflict, unmeasuredSlotRefusal, windowPageIndex };
@@ -0,0 +1,20 @@
1
+ //#region src/markdown-policy.ts
2
+ const MARKDOWN_CONVERTER_POLICY = {
3
+ urls: {
4
+ linkProtocols: [
5
+ "https:",
6
+ "http:",
7
+ "mailto:"
8
+ ],
9
+ imageProtocols: ["https:", "http:"],
10
+ linkFragments: true,
11
+ dataImages: true
12
+ },
13
+ inertControls: [{
14
+ element: "input",
15
+ type: "checkbox",
16
+ attribute: "disabled"
17
+ }]
18
+ };
19
+ //#endregion
20
+ export { MARKDOWN_CONVERTER_POLICY };
@@ -0,0 +1,29 @@
1
+ import { Page, PageBody } from "@segnavia/format";
2
+ //#region src/markdown.d.ts
3
+ export type MarkdownHeading = {
4
+ id: string;
5
+ level: number;
6
+ title: string;
7
+ };
8
+ export type AlertKind = 'note' | 'tip' | 'important' | 'warning' | 'caution';
9
+ export type AlertLabels = Record<AlertKind, string>;
10
+ export declare const alertLabels: Readonly<Record<'en' | 'he', AlertLabels>>;
11
+ /** Whether the reader recognizes a blockquote source as a GitHub alert. */
12
+ export declare function isGithubAlertBlock(raw: string): boolean;
13
+ /** Whether the reader turns a fenced language into a diagram placeholder. */
14
+ export declare function isDiagramFence(language: string | undefined): boolean;
15
+ export interface BodyHtmlOptions {
16
+ alertLabels?: Partial<AlertLabels>;
17
+ dir?: 'ltr' | 'rtl' | 'auto';
18
+ lang?: string;
19
+ }
20
+ export declare function bodyHtml(body: PageBody, options?: BodyHtmlOptions): string;
21
+ export declare function markdownOutline(markdown: string | Extract<PageBody, {
22
+ markdown: string;
23
+ }>): MarkdownHeading[];
24
+ export type HtmlPage = Omit<Page, 'body'> & {
25
+ html: string;
26
+ };
27
+ export declare function resolvePageHtml(page: Page, options: Pick<BodyHtmlOptions, 'alertLabels'>): HtmlPage;
28
+ export declare function resolvePageHtml(page: Page): HtmlPage;
29
+ //#endregion
@@ -0,0 +1,278 @@
1
+ import { MARKDOWN_CONVERTER_POLICY } from "./markdown-policy.js";
2
+ import { sanitize } from "./sanitize.js";
3
+ import { resolveDirection } from "@segnavia/format";
4
+ import rehypeStringify from "rehype-stringify";
5
+ import remarkDirective from "remark-directive";
6
+ import remarkFrontmatter from "remark-frontmatter";
7
+ import remarkGfm from "remark-gfm";
8
+ import remarkParse from "remark-parse";
9
+ import remarkRehype from "remark-rehype";
10
+ import { unified } from "unified";
11
+ import { parse } from "yaml";
12
+ //#region src/markdown.ts
13
+ const alertLabels = {
14
+ en: {
15
+ note: "Note",
16
+ tip: "Tip",
17
+ important: "Important",
18
+ warning: "Warning",
19
+ caution: "Caution"
20
+ },
21
+ he: {
22
+ note: "הערה",
23
+ tip: "עצה",
24
+ important: "חשוב",
25
+ warning: "אזהרה",
26
+ caution: "זהירות"
27
+ }
28
+ };
29
+ function alertMarker(value) {
30
+ const match = /^\[!([A-Za-z]+)\](?:\r?\n|\s*)/.exec(value);
31
+ const kind = match?.[1]?.toLowerCase();
32
+ return kind && match && Object.hasOwn(alertLabels.en, kind) ? {
33
+ kind,
34
+ length: match[0].length
35
+ } : void 0;
36
+ }
37
+ /** Whether the reader recognizes a blockquote source as a GitHub alert. */
38
+ function isGithubAlertBlock(raw) {
39
+ const content = raw.split("\n").map((line) => /^ {0,3}>[ \t]?(.*)$/.exec(line)?.[1] ?? "").find((line) => line.trim() !== "");
40
+ return Boolean(content && alertMarker(content));
41
+ }
42
+ /** Whether the reader turns a fenced language into a diagram placeholder. */
43
+ function isDiagramFence(language) {
44
+ return (language ?? "").trim().split(/[ \t]/, 1)[0]?.toLowerCase() === "mermaid";
45
+ }
46
+ const GEOMETRY_STYLE = "<style>:where([data-segnavia-overflow]){max-inline-size:100%;overflow-x:auto}:where([data-segnavia-overflow]>table){inline-size:max-content;min-inline-size:100%}@media print{:where([data-segnavia-overflow]){overflow-x:visible}:where([data-segnavia-overflow]>table){inline-size:100%;max-inline-size:100%;min-inline-size:0}:where([data-segnavia-overflow]>pre){white-space:pre-wrap}}</style>";
47
+ const memo = /* @__PURE__ */ new WeakMap();
48
+ function textNode(value) {
49
+ return {
50
+ type: "text",
51
+ value
52
+ };
53
+ }
54
+ function element(tagName, children, properties) {
55
+ return {
56
+ type: "element",
57
+ tagName,
58
+ properties,
59
+ children
60
+ };
61
+ }
62
+ function propertyValue(value, ancestors) {
63
+ if (typeof value === "boolean") return [textNode(value ? "yes" : "no")];
64
+ if (Array.isArray(value)) {
65
+ if (ancestors.has(value)) return [textNode("(cycle)")];
66
+ const next = new Set(ancestors).add(value);
67
+ return value.flatMap((item) => [element("span", propertyValue(item, next))]);
68
+ }
69
+ if (value !== null && typeof value === "object") {
70
+ if (ancestors.has(value)) return [textNode("(cycle)")];
71
+ return [propertyList(value, new Set(ancestors).add(value))];
72
+ }
73
+ return [textNode(value == null ? "" : String(value))];
74
+ }
75
+ function propertyList(properties, ancestors) {
76
+ return element("dl", Object.entries(properties).flatMap(([key, value]) => [element("dt", [textNode(key)]), element("dd", propertyValue(value, ancestors))]));
77
+ }
78
+ function unreadableFrontmatter(source) {
79
+ return [element("p", [textNode("Frontmatter could not be read.")]), element("pre", [element("code", [textNode(`---\n${source}\n---`)])])];
80
+ }
81
+ function frontmatter(source) {
82
+ let value;
83
+ try {
84
+ value = parse(source);
85
+ } catch {
86
+ return unreadableFrontmatter(source);
87
+ }
88
+ if (value == null) return [];
89
+ if (typeof value !== "object" || Array.isArray(value)) return unreadableFrontmatter(source);
90
+ const properties = value;
91
+ return Object.keys(properties).length === 0 ? [] : propertyList(properties, /* @__PURE__ */ new Set([properties])).children ?? [];
92
+ }
93
+ function visibleText(node) {
94
+ if (node.type === "text" || node.type === "inlineCode") return node.value ?? "";
95
+ return (node.children ?? []).map(visibleText).join("");
96
+ }
97
+ function slug(title) {
98
+ return title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "section";
99
+ }
100
+ function directiveSource(node, source) {
101
+ const start = node.position?.start.offset;
102
+ const end = node.position && "end" in node.position ? node.position.end?.offset : void 0;
103
+ return start === void 0 || end === void 0 ? `:${node.name ?? ""}` : source.slice(start, end);
104
+ }
105
+ function applyFrontmatter(node) {
106
+ if (node.type !== "yaml" || typeof node.value !== "string") return;
107
+ node.type = "paragraph";
108
+ node.children = [];
109
+ node.data = {
110
+ hName: "dl",
111
+ hProperties: { dataSegnaviaFrontmatter: "" },
112
+ hChildren: frontmatter(node.value)
113
+ };
114
+ }
115
+ function applyHeading(node, seen, outline) {
116
+ if (node.type !== "heading" || !node.depth) return;
117
+ const title = visibleText(node);
118
+ const base = slug(title);
119
+ const count = seen.get(base) ?? 0;
120
+ seen.set(base, count + 1);
121
+ const id = count === 0 ? base : `${base}-${count + 1}`;
122
+ node.data = {
123
+ ...node.data,
124
+ hProperties: {
125
+ ...node.data?.hProperties,
126
+ id
127
+ }
128
+ };
129
+ outline.push({
130
+ id,
131
+ level: node.depth,
132
+ title
133
+ });
134
+ }
135
+ function applyDirective(node) {
136
+ if (node.type !== "containerDirective") return;
137
+ const attributes = Object.fromEntries(Object.entries(node.attributes ?? {}).map(([name, value]) => [`dataSegnaviaDirective${name.charAt(0).toUpperCase()}${name.slice(1).replace(/[^a-zA-Z0-9]+(.)/g, (_, next) => next.toUpperCase())}`, value ?? ""]));
138
+ node.data = {
139
+ hName: "div",
140
+ hProperties: {
141
+ dataSegnaviaDirective: node.name ?? "",
142
+ ...attributes
143
+ }
144
+ };
145
+ }
146
+ function applyAlert(node, labels) {
147
+ if (node.type !== "blockquote") return;
148
+ const first = node.children?.[0]?.children?.find((child) => child.type === "text");
149
+ if (!first) return;
150
+ const marker = alertMarker(first.value ?? "");
151
+ if (!marker) return;
152
+ const { kind } = marker;
153
+ first.value = (first.value ?? "").slice(marker.length);
154
+ node.data = {
155
+ hName: "div",
156
+ hProperties: { dataSegnaviaAlert: kind }
157
+ };
158
+ node.children = [{
159
+ type: "paragraph",
160
+ children: [{
161
+ type: "text",
162
+ value: labels[kind]
163
+ }],
164
+ data: { hName: "bdi" }
165
+ }, ...node.children ?? []];
166
+ }
167
+ function literalDirectives(node, source) {
168
+ if (!node.children) return;
169
+ node.children = node.children.map((child) => child.type === "textDirective" || child.type === "leafDirective" ? {
170
+ type: "text",
171
+ value: directiveSource(child, source)
172
+ } : child);
173
+ }
174
+ function markdownPlugin(outline, labels) {
175
+ return (tree, file) => {
176
+ const source = String(file.value);
177
+ const seen = /* @__PURE__ */ new Map();
178
+ const visit = (node) => {
179
+ applyFrontmatter(node);
180
+ applyHeading(node, seen, outline);
181
+ applyDirective(node);
182
+ applyAlert(node, labels);
183
+ literalDirectives(node, source);
184
+ for (const child of node.children ?? []) visit(child);
185
+ };
186
+ visit(tree);
187
+ };
188
+ }
189
+ function alertLanguage(markdown, options) {
190
+ const primary = options.lang?.trim().toLowerCase().split(/[-_]/)[0];
191
+ if (primary === "en" || primary === "he") return primary;
192
+ const text = markdown.replace(/^>\s*\[![A-Za-z]+\]\s*$/gm, "");
193
+ return resolveDirection(options.dir, options.lang, text) === "rtl" ? "he" : "en";
194
+ }
195
+ function hastPlugin() {
196
+ return (tree) => {
197
+ const wrap = (node) => {
198
+ const copy = {
199
+ ...node,
200
+ children: node.children
201
+ };
202
+ node.tagName = "div";
203
+ node.properties = { dataSegnaviaOverflow: "" };
204
+ node.children = [copy];
205
+ };
206
+ const transform = (node) => {
207
+ if (node.type !== "element") return;
208
+ if (node.tagName === "table") wrap(node);
209
+ if (node.tagName !== "pre") return;
210
+ const code = node.children?.[0];
211
+ if ((Array.isArray(code?.properties?.className) ? code.properties.className.map(String) : []).some((name) => name.startsWith("language-") && isDiagramFence(name.slice(9)))) node.properties = {
212
+ ...node.properties,
213
+ dataSegnaviaDiagram: "mermaid"
214
+ };
215
+ else wrap(node);
216
+ };
217
+ const visit = (node) => {
218
+ for (const child of node.children ?? []) visit(child);
219
+ transform(node);
220
+ };
221
+ visit(tree);
222
+ };
223
+ }
224
+ function convert(markdown, options) {
225
+ const outline = [];
226
+ const language = alertLanguage(markdown, options);
227
+ const labels = {
228
+ ...alertLabels[language],
229
+ ...options.alertLabels
230
+ };
231
+ const result = unified().use(remarkParse).use(remarkGfm).use(remarkFrontmatter).use(remarkDirective).use(markdownPlugin, outline, labels).use(remarkRehype).use(hastPlugin).use(rehypeStringify).processSync(markdown);
232
+ return {
233
+ html: `${GEOMETRY_STYLE}${sanitize(String(result), { converterPolicy: MARKDOWN_CONVERTER_POLICY })}`,
234
+ outline
235
+ };
236
+ }
237
+ function optionKey(options) {
238
+ return JSON.stringify([
239
+ options.dir,
240
+ options.lang,
241
+ options.alertLabels
242
+ ]);
243
+ }
244
+ function markdownResult(body, options) {
245
+ let entries = memo.get(body);
246
+ if (!entries) {
247
+ entries = /* @__PURE__ */ new Map();
248
+ memo.set(body, entries);
249
+ }
250
+ const key = optionKey(options);
251
+ const found = entries.get(key);
252
+ if (found !== void 0) return found;
253
+ const result = convert(body.markdown, options);
254
+ entries.set(key, result);
255
+ return result;
256
+ }
257
+ function outlinedMarkdownResult(body) {
258
+ return memo.get(body)?.values().next().value ?? markdownResult(body, {});
259
+ }
260
+ function bodyHtml(body, options = {}) {
261
+ return "html" in body ? body.html : markdownResult(body, options).html;
262
+ }
263
+ function markdownOutline(markdown) {
264
+ return outlinedMarkdownResult(typeof markdown === "string" ? { markdown } : markdown).outline;
265
+ }
266
+ function resolvePageHtml(page, options = {}) {
267
+ const { body, ...fields } = page;
268
+ return {
269
+ ...fields,
270
+ html: bodyHtml(body, {
271
+ dir: page.dir,
272
+ lang: page.lang,
273
+ alertLabels: options.alertLabels
274
+ })
275
+ };
276
+ }
277
+ //#endregion
278
+ export { alertLabels, bodyHtml, isDiagramFence, isGithubAlertBlock, markdownOutline, resolvePageHtml };
@@ -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 };
@@ -7,7 +7,25 @@ export interface SanitizeOptions {
7
7
  dependencyOrigins?: Iterable<string>;
8
8
  dataFonts?: boolean;
9
9
  refuseElements?: Iterable<string>;
10
+ converterPolicy?: ConverterPolicy;
10
11
  }
12
+ export interface UrlPolicy {
13
+ linkProtocols: readonly string[];
14
+ imageProtocols: readonly string[];
15
+ linkFragments: boolean;
16
+ dataImages: boolean;
17
+ }
18
+ export interface ConverterPolicy {
19
+ urls: UrlPolicy;
20
+ inertControls: readonly {
21
+ element: 'input';
22
+ type: 'checkbox';
23
+ attribute: 'disabled';
24
+ }[];
25
+ }
26
+ export declare function safeImageSource(value: string): boolean;
27
+ /** The URL decision used when schema-based authoring admits a link or image. */
28
+ export declare function safeMarkdownUrl(attribute: 'href' | 'src', value: string, linkOrigins?: Iterable<string>): boolean;
11
29
  export declare function findActiveContent(html: string, options?: SanitizeOptions): string[];
12
30
  export declare function sanitize(html: string, options?: SanitizeOptions): string;
13
31
  //#endregion
package/dist/sanitize.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { MARKDOWN_CONVERTER_POLICY } from "./markdown-policy.js";
1
2
  import { sanitizeCss } from "./sanitize-css.js";
2
3
  import { isTag } from "domhandler";
3
4
  import { dependencyDecision, dependencyOriginSet, originSet } from "@segnavia/format";
@@ -80,25 +81,50 @@ function insideSvg(element) {
80
81
  function normalizeUrl(value) {
81
82
  return [...value].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("").toLowerCase();
82
83
  }
83
- function safeUrl(attribute, value, allowed) {
84
+ function safeImageSource(value) {
85
+ return DATA_IMAGE.test(normalizeUrl(value));
86
+ }
87
+ function safeUrl(attribute, value, allowed, policy) {
84
88
  const url = normalizeUrl(value);
85
89
  if (attribute === "href" || attribute === "xlink:href") {
86
- if (url.startsWith("#")) return true;
90
+ if (url.startsWith("#")) return policy?.linkFragments ?? true;
87
91
  try {
88
92
  const parsed = new URL(url);
93
+ if (policy) return policy.linkProtocols.includes(parsed.protocol);
89
94
  return parsed.protocol === "https:" && allowed.has(parsed.origin);
90
95
  } catch {
91
96
  return false;
92
97
  }
93
98
  }
94
99
  if (attribute === "srcset") return DATA_IMAGE_SRCSET.test(url);
95
- if (attribute === "src" || attribute === "poster") return DATA_IMAGE.test(url);
100
+ if (attribute === "src" || attribute === "poster") {
101
+ if (policy?.dataImages && safeImageSource(value)) return true;
102
+ if (!policy) return safeImageSource(value);
103
+ try {
104
+ return policy.imageProtocols.includes(new URL(url).protocol);
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
96
109
  return false;
97
110
  }
98
- function refusedElement(element, refused, keepScripts) {
111
+ /** The URL decision used when schema-based authoring admits a link or image. */
112
+ function safeMarkdownUrl(attribute, value, linkOrigins) {
113
+ if (!safeUrl(attribute, value, /* @__PURE__ */ new Set(), MARKDOWN_CONVERTER_POLICY.urls)) return false;
114
+ if (attribute === "src" || linkOrigins === void 0 || value.startsWith("#")) return true;
115
+ let parsed;
116
+ try {
117
+ parsed = new URL(value);
118
+ } catch {
119
+ return true;
120
+ }
121
+ return parsed.protocol !== "http:" && parsed.protocol !== "https:" || originSet(linkOrigins).has(parsed.origin.toLowerCase());
122
+ }
123
+ function refusedElement(element, refused, options, keepScripts) {
99
124
  const name = element.name.toLowerCase();
100
125
  if (name === "script") return !keepScripts;
101
126
  if (name === "link" && keepScripts && element.attribs.rel?.toLowerCase() === "stylesheet") return false;
127
+ if (options.converterPolicy?.inertControls.some((control) => control.element === name && element.attribs.type?.toLowerCase() === control.type && control.attribute in element.attribs)) return false;
102
128
  if (refused.has(name)) return true;
103
129
  if (insideSvg(element) && REFUSED_SVG_ELEMENTS.has(name)) return true;
104
130
  return name === "meta" && Object.keys(element.attribs).some((attribute) => attribute.toLowerCase() === "http-equiv");
@@ -115,7 +141,7 @@ function refusedAttribute(element, name, value, allowed, options, dependencies)
115
141
  if (attribute.startsWith("on")) return options.keepScripts ? void 0 : "";
116
142
  const dependency = dependencyAttribute(element, attribute, value, options, dependencies);
117
143
  if (dependency !== void 0) return dependency || void 0;
118
- return REFUSED_ATTRIBUTES.has(attribute) || URL_ATTRIBUTES.has(attribute) && !safeUrl(attribute, value, allowed) ? "" : void 0;
144
+ return REFUSED_ATTRIBUTES.has(attribute) || URL_ATTRIBUTES.has(attribute) && !safeUrl(attribute, value, allowed, options.converterPolicy?.urls) ? "" : void 0;
119
145
  }
120
146
  function refusedSet(options) {
121
147
  const defaults = options.keepScripts ? Object.entries(REFUSED).filter(([, rule]) => rule === "always").map(([name]) => name) : REFUSED_ELEMENTS;
@@ -165,7 +191,7 @@ function findActiveContent(html, options = {}) {
165
191
  const keepScripts = options.keepScripts ?? false;
166
192
  const found = [];
167
193
  for (const element of elements(parse(html).children)) {
168
- if (refusedElement(element, refused, keepScripts)) found.push(`<${element.name}>`);
194
+ if (refusedElement(element, refused, options, keepScripts)) found.push(`<${element.name}>`);
169
195
  found.push(...styleFindings(element, options, dependencies));
170
196
  for (const [name, value] of Object.entries(element.attribs)) {
171
197
  const reason = refusedAttribute(element, name, value, allowed, options, dependencies);
@@ -198,7 +224,7 @@ function sanitize(html, options = {}) {
198
224
  const document = parse(html);
199
225
  const prune = (nodes) => nodes.filter((node) => {
200
226
  if (!isTag(node)) return true;
201
- if (refusedElement(node, refused, keepScripts)) return false;
227
+ if (refusedElement(node, refused, options, keepScripts)) return false;
202
228
  cleanStyleElement(node, options, dependencies);
203
229
  cleanAttributes(node, allowed, options, dependencies);
204
230
  node.children = prune(node.children);
@@ -208,4 +234,4 @@ function sanitize(html, options = {}) {
208
234
  return serialize(document);
209
235
  }
210
236
  //#endregion
211
- export { REFUSED_ELEMENTS, elements, findActiveContent, parse, sanitize, serialize };
237
+ export { REFUSED_ELEMENTS, elements, findActiveContent, parse, safeImageSource, safeMarkdownUrl, sanitize, serialize };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segnavia/html",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,10 +26,18 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@segnavia/format": "0.38.0",
29
+ "@segnavia/format": "0.40.0",
30
30
  "dom-serializer": "^2.0.0",
31
31
  "domhandler": "^5.0.3",
32
32
  "htmlparser2": "^10.0.0",
33
- "qrcode-generator": "2.0.4"
33
+ "qrcode-generator": "2.0.4",
34
+ "rehype-stringify": "10.0.1",
35
+ "remark-directive": "4.0.0",
36
+ "remark-frontmatter": "5.0.0",
37
+ "remark-gfm": "4.0.1",
38
+ "remark-parse": "11.0.0",
39
+ "remark-rehype": "11.1.2",
40
+ "unified": "11.0.5",
41
+ "yaml": "2.9.1"
34
42
  }
35
43
  }