@segnavia/html 0.3.0 → 0.5.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.
@@ -0,0 +1,5 @@
1
+ import { BookDocument, BookDocumentOptions } from "./types.js";
2
+ import { Book } from "@segnavia/format";
3
+ //#region src/book/book-document.d.ts
4
+ export declare function bookDocument(book: Book, options?: BookDocumentOptions): BookDocument;
5
+ //#endregion
@@ -0,0 +1,217 @@
1
+ import { elements, parse, serialize } from "../sanitize.js";
2
+ import { element, linkChildren, text, textContent } from "./dom.js";
3
+ import { noteState, transformNotes } from "./notes.js";
4
+ import { bookPrintPart } from "./types.js";
5
+ import { isTag } from "domhandler";
6
+ import { isBookContents, noteMarkerIssues, resolveDirection } from "@segnavia/format";
7
+ //#region src/book/book-document.ts
8
+ const PRINT_CSS = ":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(.segnavia-contents-entry[data-level=\"2\"]){padding-inline-start:1em}:where(.segnavia-contents-entry[data-level=\"3\"]){padding-inline-start:2em}:where(.segnavia-contents-entry[data-level=\"4\"]){padding-inline-start:3em}:where(.segnavia-contents-entry[data-level=\"5\"]){padding-inline-start:4em}:where(.segnavia-contents-entry[data-level=\"6\"]){padding-inline-start:5em}@media print{:where(.segnavia-front,.segnavia-contents)~:where(.segnavia-front,.segnavia-contents),:where(.segnavia-chapter,.segnavia-endnotes,.segnavia-blank){break-before:page}:where(.segnavia-blank){break-after:page}:where(.segnavia-targets){position:absolute;inline-size:1px;block-size:1px;overflow:hidden;clip-path:inset(50%)}}";
9
+ function direction(book, value) {
10
+ return resolveDirection(book.dir, book.lang, value);
11
+ }
12
+ function sectionNode(kind, id, children = []) {
13
+ return element("section", {
14
+ class: `segnavia-section segnavia-${kind}`,
15
+ id
16
+ }, children);
17
+ }
18
+ function authoredSection(section, id) {
19
+ const node = sectionNode("front", id, parse(section.html).children);
20
+ return {
21
+ info: {
22
+ id,
23
+ kind: "front",
24
+ title: section.title,
25
+ runningHead: section.title
26
+ },
27
+ node
28
+ };
29
+ }
30
+ function contentsSection(title) {
31
+ const id = "segnavia-contents";
32
+ const children = title !== void 0 ? [element("h1", {
33
+ class: "segnavia-contents-title",
34
+ dir: "ltr"
35
+ }, [text(title)])] : [];
36
+ return {
37
+ info: {
38
+ id,
39
+ kind: "contents",
40
+ title,
41
+ runningHead: title
42
+ },
43
+ node: sectionNode("contents", id, children)
44
+ };
45
+ }
46
+ function chapterSection(chapter, index) {
47
+ const id = `segnavia-chapter-${index}`;
48
+ const document = parse(chapter.html);
49
+ return {
50
+ info: {
51
+ id,
52
+ kind: "chapter",
53
+ title: chapter.title,
54
+ runningHead: chapter.runningHead ?? chapter.title
55
+ },
56
+ node: sectionNode("chapter", id, document.children)
57
+ };
58
+ }
59
+ function chapterHeadings(section, chapter, depth) {
60
+ let generated = 0;
61
+ return elements(section.node.children).flatMap((heading) => {
62
+ const match = /^h([2-6])$/.exec(heading.name);
63
+ if (!match || heading.attribs["data-contents"] === "exclude") return [];
64
+ const level = Number(match[1]);
65
+ if (level > depth) return [];
66
+ generated += 1;
67
+ const id = heading.attribs.id ?? `segnavia-h-${chapter}-${generated}`;
68
+ heading.attribs.id = id;
69
+ return [{
70
+ id,
71
+ level,
72
+ text: textContent(heading, true).replace(/\s+/g, " ").trim(),
73
+ sectionId: section.info.id
74
+ }];
75
+ });
76
+ }
77
+ function contentsList(book, headings, options) {
78
+ const entries = headings.map((heading) => {
79
+ const page = book.contents.pageNumbers ? options.pageNumbers?.[heading.id] ?? "" : "";
80
+ return element("li", {
81
+ class: "segnavia-contents-entry",
82
+ "data-level": String(heading.level)
83
+ }, [element("a", {
84
+ href: options.crossLinks ? `segnavia:target/${heading.id}` : `#${heading.id}`,
85
+ dir: direction(book, heading.text)
86
+ }, [text(heading.text)]), element("span", {
87
+ class: "segnavia-contents-page",
88
+ "data-target": heading.id,
89
+ dir: direction(book, page)
90
+ }, [text(page)])]);
91
+ });
92
+ return element("ol", { class: "segnavia-contents-list" }, entries);
93
+ }
94
+ function addContents(book, section, headings, options) {
95
+ const title = section.info.title;
96
+ const titleNode = section.node.children.find((child) => isTag(child) && child.name === "h1");
97
+ if (title !== void 0 && titleNode && isTag(titleNode)) titleNode.attribs.dir = direction(book, title);
98
+ section.node.children.push(contentsList(book, headings, options));
99
+ linkChildren(section.node);
100
+ }
101
+ function targetLinks(sections) {
102
+ const ids = sections.flatMap((section) => [section.info.id, ...elements(section.node.children).flatMap((node) => node.attribs.id ? [node.attribs.id] : [])]);
103
+ return element("div", {
104
+ class: "segnavia-targets",
105
+ "aria-hidden": "true"
106
+ }, [...new Set(ids)].map((id) => element("a", { href: `#${id}` }, [text("​")])));
107
+ }
108
+ function duplicateIdIssues(sections, headings) {
109
+ const listed = new Set(headings.map((heading) => heading.id));
110
+ const occurrences = /* @__PURE__ */ new Map();
111
+ for (const section of sections) {
112
+ const ids = [section.info.id, ...elements(section.node.children).flatMap((node) => node.attribs.id ? [node.attribs.id] : [])];
113
+ for (const id of ids) occurrences.set(id, [...occurrences.get(id) ?? [], section.info]);
114
+ }
115
+ return [...occurrences].flatMap(([id, owners]) => {
116
+ if (!listed.has(id) || owners.length < 2) return [];
117
+ const named = owners.slice(0, 2).map((owner) => `${owner.title ?? owner.id} (${owner.id})`);
118
+ return [`destination id ${id} is used in both ${named[0]} and ${named[1]}; listed heading and generated destination ids must be unique`];
119
+ });
120
+ }
121
+ function reservedIdIssues(section) {
122
+ return elements(section.node.children).flatMap((node) => {
123
+ const id = node.attribs.id;
124
+ if (!id?.startsWith("segnavia-")) return [];
125
+ return [`authored id ${id} in ${section.info.title ?? section.info.id} (${section.info.id}) uses the reserved segnavia- prefix`];
126
+ });
127
+ }
128
+ function endnotesSection(book, paragraphs) {
129
+ const id = "segnavia-endnotes";
130
+ const titleValue = book.endnotes.title;
131
+ const titleNode = element("h1", {
132
+ class: "segnavia-endnotes-title",
133
+ dir: direction(book, titleValue)
134
+ }, [text(titleValue)]);
135
+ return {
136
+ info: {
137
+ id,
138
+ kind: "endnotes",
139
+ title: titleValue,
140
+ runningHead: titleValue
141
+ },
142
+ node: sectionNode("endnotes", id, [titleNode, ...paragraphs])
143
+ };
144
+ }
145
+ function withBlankPages(sections, blankBefore) {
146
+ const counts = /* @__PURE__ */ new Map();
147
+ for (const id of blankBefore ?? []) counts.set(id, (counts.get(id) ?? 0) + 1);
148
+ return sections.flatMap((section) => [...Array.from({ length: counts.get(section.info.id) ?? 0 }, () => element("div", {
149
+ class: "segnavia-blank",
150
+ "aria-hidden": "true"
151
+ })), section.node]);
152
+ }
153
+ function bookDocument(book, options = {}) {
154
+ const notes = noteState();
155
+ const sections = [];
156
+ const headings = [];
157
+ const issues = [];
158
+ let frontNumber = 0;
159
+ let contents;
160
+ for (const entry of book.frontMatter) {
161
+ if (isBookContents(entry)) {
162
+ contents = contentsSection(entry.title);
163
+ sections.push(contents);
164
+ continue;
165
+ }
166
+ frontNumber += 1;
167
+ const section = authoredSection(entry, `segnavia-front-${frontNumber}`);
168
+ sections.push(section);
169
+ issues.push(...reservedIdIssues(section));
170
+ if (entry.contents && entry.title !== void 0) headings.push({
171
+ id: section.info.id,
172
+ level: 1,
173
+ text: entry.title,
174
+ sectionId: section.info.id
175
+ });
176
+ transformNotes(section.node, book, 0, notes);
177
+ }
178
+ for (const [offset, chapter] of book.chapters.entries()) {
179
+ const number = offset + 1;
180
+ const section = chapterSection(chapter, number);
181
+ sections.push(section);
182
+ issues.push(...reservedIdIssues(section));
183
+ headings.push({
184
+ id: section.info.id,
185
+ level: 1,
186
+ text: chapter.title,
187
+ sectionId: section.info.id
188
+ }, ...chapterHeadings(section, number, book.contents.depth));
189
+ transformNotes(section.node, book, number, notes);
190
+ }
191
+ if (notes.endnotes) sections.push(endnotesSection(book, notes.endnoteParagraphs));
192
+ if (book.endnotes.placement === "chapter") issues.push("endnotes at the end of each chapter belong to paginated mode, which is not built yet");
193
+ if (contents) addContents(book, contents, headings, options);
194
+ const visible = sections.filter((section) => options.part === void 0 || bookPrintPart(section.info.kind) === options.part);
195
+ const document = parse("");
196
+ document.children = [
197
+ element("style", {}, [text(PRINT_CSS)]),
198
+ ...options.targets ? [targetLinks(visible)] : [],
199
+ ...withBlankPages(visible, options.blankBefore)
200
+ ];
201
+ linkChildren(document);
202
+ return {
203
+ html: serialize(document),
204
+ sections: sections.map((section) => section.info),
205
+ headings,
206
+ issues: [
207
+ ...noteMarkerIssues(book, {
208
+ footnotes: notes.footnotes,
209
+ endnotes: notes.endnotes
210
+ }),
211
+ ...issues,
212
+ ...duplicateIdIssues(sections, headings)
213
+ ]
214
+ };
215
+ }
216
+ //#endregion
217
+ export { bookDocument };
@@ -0,0 +1,46 @@
1
+ import { Element, Text, isTag } from "domhandler";
2
+ //#region src/book/dom.ts
3
+ function element(name, attribs = {}, children = []) {
4
+ const node = new Element(name, attribs, children);
5
+ linkChildren(node);
6
+ return node;
7
+ }
8
+ function text(value) {
9
+ return new Text(value);
10
+ }
11
+ function linkChildren(parent) {
12
+ for (const [index, child] of parent.children.entries()) {
13
+ child.parent = parent;
14
+ child.prev = parent.children[index - 1] ?? null;
15
+ child.next = parent.children[index + 1] ?? null;
16
+ if (isTag(child)) linkChildren(child);
17
+ }
18
+ }
19
+ function replace(node, replacement) {
20
+ const parent = node.parent;
21
+ if (!parent) return;
22
+ const index = parent.children.indexOf(node);
23
+ if (index < 0) return;
24
+ parent.children[index] = replacement;
25
+ linkChildren(parent);
26
+ }
27
+ function insertAfter(node, addition) {
28
+ const parent = node.parent;
29
+ if (!parent) return;
30
+ const index = parent.children.indexOf(node);
31
+ if (index < 0) return;
32
+ parent.children.splice(index + 1, 0, addition);
33
+ linkChildren(parent);
34
+ }
35
+ function append(parent, addition) {
36
+ parent.children.push(addition);
37
+ linkChildren(parent);
38
+ }
39
+ function textContent(node, omitNotes = false) {
40
+ if ("data" in node) return node.data;
41
+ if (!("children" in node)) return "";
42
+ if (omitNotes && isTag(node) && node.name === "span" && node.attribs["data-note"] !== void 0) return "";
43
+ return node.children.map((child) => textContent(child, omitNotes)).join("");
44
+ }
45
+ //#endregion
46
+ export { append, element, insertAfter, linkChildren, replace, text, textContent };
@@ -0,0 +1,112 @@
1
+ import { elements } from "../sanitize.js";
2
+ import { append, element, insertAfter, replace, text } from "./dom.js";
3
+ import { isTag } from "domhandler";
4
+ import { noteMarker } from "@segnavia/format";
5
+ //#region src/book/notes.ts
6
+ const BLOCKS = /* @__PURE__ */ new Set([
7
+ "p",
8
+ "li",
9
+ "blockquote",
10
+ "dd",
11
+ "dt",
12
+ "figcaption",
13
+ "td",
14
+ "th",
15
+ "pre",
16
+ "h1",
17
+ "h2",
18
+ "h3",
19
+ "h4",
20
+ "h5",
21
+ "h6",
22
+ "div",
23
+ "section",
24
+ "article"
25
+ ]);
26
+ const INSIDE_BLOCKS = /* @__PURE__ */ new Set([
27
+ "li",
28
+ "dd",
29
+ "dt",
30
+ "td",
31
+ "th"
32
+ ]);
33
+ function noteState() {
34
+ return {
35
+ footnotes: false,
36
+ endnotes: false,
37
+ globalFootnote: 0,
38
+ chapterFootnotes: /* @__PURE__ */ new Map(),
39
+ endnoteCount: 0,
40
+ endnoteParagraphs: []
41
+ };
42
+ }
43
+ function nearestBlock(note) {
44
+ for (let parent = note.parent; parent; parent = parent.parent) if (isTag(parent) && BLOCKS.has(parent.name)) return parent;
45
+ }
46
+ function markerLink(id, href, marker) {
47
+ return element("a", {
48
+ id,
49
+ href: `#${href}`
50
+ }, [element("bdi", {}, [text(marker)])]);
51
+ }
52
+ function noteParagraph(className, noteId, refId, marker, content) {
53
+ return element("p", {
54
+ class: className,
55
+ id: noteId
56
+ }, [
57
+ element("a", { href: `#${refId}` }, [element("bdi", {}, [text(marker)])]),
58
+ text(" "),
59
+ ...content
60
+ ]);
61
+ }
62
+ function footnoteNumber(book, chapter, state) {
63
+ if (book.footnotes.restart === "book") {
64
+ state.globalFootnote += 1;
65
+ return state.globalFootnote;
66
+ }
67
+ const number = (state.chapterFootnotes.get(chapter) ?? 0) + 1;
68
+ state.chapterFootnotes.set(chapter, number);
69
+ return number;
70
+ }
71
+ function addFootnoteBlock(block, paragraph, blocks) {
72
+ const existing = blocks.get(block);
73
+ if (existing) {
74
+ append(existing, paragraph);
75
+ return;
76
+ }
77
+ const notes = element("div", { class: "segnavia-footnotes" }, [paragraph]);
78
+ blocks.set(block, notes);
79
+ if (INSIDE_BLOCKS.has(block.name)) append(block, notes);
80
+ else insertAfter(block, notes);
81
+ }
82
+ function transformFootnote(note, book, chapter, state, blocks) {
83
+ const block = nearestBlock(note);
84
+ if (!block) return;
85
+ const number = footnoteNumber(book, chapter, state);
86
+ const noteId = `segnavia-fn-${chapter}-${number}`;
87
+ const refId = `segnavia-fnref-${chapter}-${number}`;
88
+ const marker = noteMarker(number, book.footnotes.marker);
89
+ const content = [...note.children];
90
+ replace(note, element("sup", { class: "segnavia-note-ref" }, [markerLink(refId, noteId, marker)]));
91
+ addFootnoteBlock(block, noteParagraph("segnavia-footnote", noteId, refId, marker, content), blocks);
92
+ state.footnotes = true;
93
+ }
94
+ function transformEndnote(note, book, state) {
95
+ state.endnoteCount += 1;
96
+ const number = state.endnoteCount;
97
+ const noteId = `segnavia-en-${number}`;
98
+ const refId = `segnavia-enref-${number}`;
99
+ const marker = noteMarker(number, book.endnotes.marker);
100
+ const content = [...note.children];
101
+ replace(note, element("sup", { class: "segnavia-note-ref" }, [markerLink(refId, noteId, marker)]));
102
+ state.endnoteParagraphs.push(noteParagraph("segnavia-endnote", noteId, refId, marker, content));
103
+ state.endnotes = true;
104
+ }
105
+ function transformNotes(root, book, chapter, state) {
106
+ const blocks = /* @__PURE__ */ new Map();
107
+ const notes = elements(root.children).filter((node) => node.name === "span" && (node.attribs["data-note"] === "footnote" || node.attribs["data-note"] === "endnote"));
108
+ for (const note of notes) if (note.attribs["data-note"] === "footnote") transformFootnote(note, book, chapter, state, blocks);
109
+ else transformEndnote(note, book, state);
110
+ }
111
+ //#endregion
112
+ export { noteState, transformNotes };
@@ -0,0 +1,29 @@
1
+ //#region src/book/types.d.ts
2
+ export interface BookDocumentOptions {
3
+ pageNumbers?: Record<string, string>;
4
+ blankBefore?: string[];
5
+ part?: 'front' | 'body';
6
+ crossLinks?: boolean;
7
+ targets?: boolean;
8
+ }
9
+ export interface BookSectionInfo {
10
+ id: string;
11
+ kind: 'front' | 'contents' | 'chapter' | 'endnotes';
12
+ title?: string;
13
+ runningHead?: string;
14
+ }
15
+ export type BookPrintPart = 'front' | 'body';
16
+ export declare function bookPrintPart(kind: BookSectionInfo['kind']): BookPrintPart;
17
+ export interface BookHeading {
18
+ id: string;
19
+ level: number;
20
+ text: string;
21
+ sectionId: string;
22
+ }
23
+ export interface BookDocument {
24
+ html: string;
25
+ sections: BookSectionInfo[];
26
+ headings: BookHeading[];
27
+ issues: string[];
28
+ }
29
+ //#endregion
@@ -0,0 +1,6 @@
1
+ //#region src/book/types.ts
2
+ function bookPrintPart(kind) {
3
+ return kind === "front" || kind === "contents" ? "front" : "body";
4
+ }
5
+ //#endregion
6
+ export { bookPrintPart };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { REFUSED_ELEMENTS, SanitizeOptions, findActiveContent, sanitize } from "./sanitize.js";
2
2
  import { BINDINGS, FillOptions, Filled, bindingNames, fill, hasBindings } from "./bindings.js";
3
+ import { BookDocument, BookDocumentOptions, BookHeading, BookPrintPart, BookSectionInfo, bookPrintPart } from "./book/types.js";
4
+ import { bookDocument } from "./book/book-document.js";
3
5
  //#region src/index.d.ts
4
6
  export type HtmlKind = 'plain' | 'live-data' | 'scripted' | 'refused';
5
7
  export declare function classify(html: string, prefix: string): HtmlKind;
6
8
  export declare function searchText(html: string): string;
7
9
  //#endregion
8
- export { BINDINGS, type FillOptions, type Filled, REFUSED_ELEMENTS, type SanitizeOptions, bindingNames, fill, findActiveContent, hasBindings, sanitize };
10
+ export { BINDINGS, type BookDocument, type BookDocumentOptions, type BookHeading, type BookPrintPart, type BookSectionInfo, type FillOptions, type Filled, REFUSED_ELEMENTS, type SanitizeOptions, bindingNames, bookDocument, bookPrintPart, fill, findActiveContent, hasBindings, sanitize };
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { REFUSED_ELEMENTS, elements, findActiveContent, parse, sanitize } from "./sanitize.js";
2
2
  import { BINDINGS, bindingNames, fill, hasBindings } from "./bindings.js";
3
+ import { bookPrintPart } from "./book/types.js";
4
+ import { bookDocument } from "./book/book-document.js";
3
5
  //#region src/index.ts
4
6
  function classify(html, prefix) {
5
7
  const scripted = elements(parse(html).children).some((element) => element.name.toLowerCase() === "script");
@@ -12,4 +14,4 @@ function searchText(html) {
12
14
  return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
13
15
  }
14
16
  //#endregion
15
- export { BINDINGS, REFUSED_ELEMENTS, bindingNames, classify, fill, findActiveContent, hasBindings, sanitize, searchText };
17
+ export { BINDINGS, REFUSED_ELEMENTS, bindingNames, bookDocument, bookPrintPart, classify, fill, findActiveContent, hasBindings, sanitize, searchText };
@@ -1,4 +1,4 @@
1
- import "domhandler";
1
+ import { Element } from "domhandler";
2
2
  //#region src/sanitize.d.ts
3
3
  export declare const REFUSED_ELEMENTS: Set<string>;
4
4
  export interface SanitizeOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segnavia/html",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
+ "@segnavia/format": "0.5.0",
29
30
  "htmlparser2": "^10.0.0",
30
31
  "domhandler": "^5.0.3",
31
32
  "dom-serializer": "^2.0.0"