book-kit 0.0.0-stage → 0.6.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.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +72 -0
  3. package/README.md +251 -2
  4. package/bin/book-kit.js +115 -0
  5. package/commands/_emoji_io.py +32 -0
  6. package/commands/audit-chapter-lengths.js +244 -0
  7. package/commands/check-epub-size.js +68 -0
  8. package/commands/check-pdf-page-count.js +162 -0
  9. package/commands/composite-kindle-emoji.py +85 -0
  10. package/commands/composite-print-emoji.py +98 -0
  11. package/commands/conventions.js +76 -0
  12. package/commands/convert-pdfx.sh +194 -0
  13. package/commands/download-icc-profile.sh +105 -0
  14. package/commands/fonts.py +212 -0
  15. package/commands/generate-reference-docx.py +344 -0
  16. package/commands/kindle-fonts.js +173 -0
  17. package/commands/links-to-footnotes.js +366 -0
  18. package/commands/markdownlint.js +25 -0
  19. package/commands/minify-css.js +43 -0
  20. package/commands/optimize-images.js +73 -0
  21. package/commands/parity.sh +150 -0
  22. package/commands/postprocess-epub.js +541 -0
  23. package/commands/prep-print-src.js +77 -0
  24. package/commands/prose.js +38 -0
  25. package/commands/release-files.js +89 -0
  26. package/commands/reorder-print-frontmatter.js +79 -0
  27. package/commands/strip-page-rules.js +1168 -0
  28. package/commands/strip-property-rules.js +93 -0
  29. package/commands/update-key-takeaways.js +314 -0
  30. package/commands/validate.js +283 -0
  31. package/configs/markdownlint-cli2.jsonc +98 -0
  32. package/configs/textlintrc.json +22 -0
  33. package/css/theme/01-tokens.css +83 -0
  34. package/css/theme/02-fonts.css +79 -0
  35. package/css/theme/03-prose.css +53 -0
  36. package/css/theme/04-base.css +226 -0
  37. package/css/theme/05-components.css +546 -0
  38. package/css/theme/06-utilities.css +40 -0
  39. package/css/theme/07-ereader.css +245 -0
  40. package/css/theme/08-epub.css +865 -0
  41. package/css/theme/09-back-matter.css +47 -0
  42. package/css/theme/10-dark.css +302 -0
  43. package/css/theme/11-print.css +1229 -0
  44. package/css/theme.css +38 -0
  45. package/defaults/book.config.yml +318 -0
  46. package/defaults/pandoc-base.yml +19 -0
  47. package/defaults/pandoc-docx.yml +6 -0
  48. package/defaults/pandoc-epub-fonts.yml +6 -0
  49. package/defaults/pandoc-epub.yml +15 -0
  50. package/defaults/pandoc-kindle.yml +6 -0
  51. package/emoji/1f382.png +0 -0
  52. package/emoji/1f389.png +0 -0
  53. package/emoji/1f440.png +0 -0
  54. package/emoji/1f44d.png +0 -0
  55. package/emoji/1f44f.png +0 -0
  56. package/emoji/1f454.png +0 -0
  57. package/emoji/1f4a1.png +0 -0
  58. package/emoji/1f4bb.png +0 -0
  59. package/emoji/1f602.png +0 -0
  60. package/emoji/1f603.png +0 -0
  61. package/emoji/1f604.png +0 -0
  62. package/emoji/1f60a.png +0 -0
  63. package/emoji/1f60f.png +0 -0
  64. package/emoji/1f64c.png +0 -0
  65. package/emoji/1f64f.png +0 -0
  66. package/emoji/1f914.png +0 -0
  67. package/emoji/1f923.png +0 -0
  68. package/emoji/2714.png +0 -0
  69. package/emoji/274c.png +0 -0
  70. package/filters/add-div-titles.lua +196 -0
  71. package/filters/arc-notice.lua +36 -0
  72. package/filters/body-emoji-images.lua +48 -0
  73. package/filters/callout-classes.lua +40 -0
  74. package/filters/lib/book_kit.lua +110 -0
  75. package/filters/revision.lua +17 -0
  76. package/filters/strip-comments.lua +40 -0
  77. package/filters/strip-emoji-kindle.lua +134 -0
  78. package/fonts/LICENSE-source-code-pro.md +93 -0
  79. package/fonts/LICENSE-source-sans.md +93 -0
  80. package/fonts/LICENSE-source-serif.md +93 -0
  81. package/fonts/recipe.json +41 -0
  82. package/just/book-kit.just +229 -0
  83. package/lib/acronyms.js +47 -0
  84. package/lib/callouts.js +17 -0
  85. package/lib/config.js +211 -0
  86. package/lib/is-main.js +21 -0
  87. package/lib/lint.js +148 -0
  88. package/lib/pandoc.js +148 -0
  89. package/lib/paragraphs.js +173 -0
  90. package/lib/revision.js +35 -0
  91. package/lib/structure.js +39 -0
  92. package/lib/textlint-github-formatter.js +58 -0
  93. package/lib/validate.js +202 -0
  94. package/package.json +98 -4
  95. package/presets/business-nonfiction/CONVENTIONS.md +35 -0
  96. package/presets/business-nonfiction/preset.yml +128 -0
  97. package/profiles/pdfx-def.ps +42 -0
  98. package/requirements.txt +14 -0
  99. package/rules/no-ai-like-patterns.js +209 -0
  100. package/rules/no-assumptive.js +76 -0
  101. package/rules/no-bare-acronyms.js +129 -0
  102. package/rules/no-cliches.js +544 -0
  103. package/rules/no-cultural-idioms.js +422 -0
  104. package/rules/no-vague-intensifiers.js +94 -0
  105. package/templates/template-epub.html +79 -0
  106. package/templates/template.html +73 -0
  107. package/toolchain.json +42 -0
  108. package/validators/acronym-expansion.js +94 -0
  109. package/validators/bold-keywords.js +56 -0
  110. package/validators/callout-balance.js +74 -0
  111. package/validators/callout-divs.js +66 -0
  112. package/validators/callout-labels.js +102 -0
  113. package/validators/chapter-lengths.js +14 -0
  114. package/validators/chapter.js +57 -0
  115. package/validators/cross-references.js +45 -0
  116. package/validators/doubled-words.js +76 -0
  117. package/validators/duplicate-headings.js +52 -0
  118. package/validators/em-dashes.js +73 -0
  119. package/validators/escaped-smallcaps.js +46 -0
  120. package/validators/footnotes.js +78 -0
  121. package/validators/heading-hierarchy.js +58 -0
  122. package/validators/hypothetical-hooks.js +103 -0
  123. package/validators/index-integrity.js +28 -0
  124. package/validators/index.js +73 -0
  125. package/validators/list-punctuation.js +123 -0
  126. package/validators/min-opener-length.js +87 -0
  127. package/validators/no-bare-url-link-text.js +38 -0
  128. package/validators/no-todo-markers.js +49 -0
  129. package/validators/no-transition-todos.js +34 -0
  130. package/validators/number-range-dashes.js +72 -0
  131. package/validators/paragraph-length.js +62 -0
  132. package/validators/quote-punctuation.js +85 -0
  133. package/validators/section-intro-final-lead.js +90 -0
  134. package/validators/section-intro-has-lead.js +41 -0
  135. package/validators/section-intro-no-tldr.js +37 -0
  136. package/validators/section-intros.js +33 -0
  137. package/validators/sentence-starters.js +132 -0
  138. package/validators/smallcaps.js +67 -0
  139. package/validators/tldr-length.js +58 -0
@@ -0,0 +1,541 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Post-process a pandoc-generated EPUB to satisfy ACE (Ace by DAISY) checks
5
+ * that pandoc 3.1.x cannot emit on its own:
6
+ *
7
+ * 1. content.opf
8
+ * - add `xml:lang` to the <package> element (Ace: epub-lang)
9
+ * - inject schema.org accessibility metadata (Ace: metadata-access*)
10
+ * The intent is already declared in book.config.yml, but pandoc ignores
11
+ * those keys and --epub-metadata only passes Dublin Core through, so the
12
+ * <meta property="schema:..."> elements never reach the OPF. We add them
13
+ * here.
14
+ *
15
+ * 2. content documents + nav
16
+ * - add the matching DPUB-ARIA `role` to elements pandoc stamps with an
17
+ * `epub:type` but no role (sections, the toc nav, noterefs, footnotes)
18
+ * (axe: epub-type-has-matching-role)
19
+ * - strip `epub:type` from headings. EPUBCheck only allows
20
+ * doc-subtitle/heading/none/presentation/tab roles on headings, so the
21
+ * correct fix is to drop the redundant epub:type from the <h*> and keep
22
+ * it (now with a role) on the containing <section>.
23
+ *
24
+ * 3. "Start Reading" location (Kindle Previewer "Book Information")
25
+ * - pandoc's EPUB3 output marks the cover, title page, and TOC but never
26
+ * the start of the body, so Kindle has no "Start Reading" target and
27
+ * Previewer shows it as "not specified." We add the marker in BOTH places
28
+ * Kindle is known to read it: a `<reference type="text">` in the legacy
29
+ * OPF <guide> and an `epub:type="bodymatter"` entry in the landmarks nav.
30
+ * Both point at the first real chapter (the file holding START_READING_ANCHOR),
31
+ * so the reader opens on real content rather than the cover/copyright.
32
+ *
33
+ * The EPUB is repackaged with `mimetype` stored first (uncompressed), per the
34
+ * OCF spec. Run after pandoc in the epub/kindle build recipes:
35
+ *
36
+ * book-kit postprocess-epub dist/book.epub
37
+ *
38
+ * Idempotent: re-running on an already-processed EPUB makes no further changes.
39
+ */
40
+
41
+ import { execFileSync } from 'node:child_process';
42
+ import { chmodSync, mkdtempSync, readFileSync, writeFileSync, readdirSync, rmSync, existsSync, utimesSync } from 'node:fs';
43
+ import { tmpdir } from 'node:os';
44
+ import path from 'node:path';
45
+ import * as yaml from 'js-yaml';
46
+ import { loadConfigOrDefaults } from '../lib/config.js';
47
+ import { isMain } from '../lib/is-main.js';
48
+ import { isStructuralSlug } from '../lib/structure.js';
49
+
50
+ // epub:type -> DPUB-ARIA role. Only types that (a) ACE flags and (b) have a
51
+ // valid DPUB role belong here. Structural types without an ARIA equivalent
52
+ // (bodymatter/frontmatter/backmatter/titlepage/landmarks) are intentionally
53
+ // omitted — ACE does not require a role for them.
54
+ //
55
+ // ARIA roles are context-restricted, so the map is split by element. Structural
56
+ // roles (doc-toc, doc-prologue, …) are only valid on sectioning elements; on an
57
+ // <a> the only valid doc-* roles are link types (doc-noteref/doc-backlink/…).
58
+ // The landmarks nav, for example, contains <a epub:type="toc"> — which must NOT
59
+ // receive doc-toc (EPUBCheck RSC-005), while the real <nav epub:type="toc">
60
+ // must.
61
+ const LINK_ROLE_MAP = {
62
+ noteref: 'doc-noteref',
63
+ };
64
+ const STRUCT_ROLE_MAP = {
65
+ toc: 'doc-toc',
66
+ introduction: 'doc-introduction',
67
+ prologue: 'doc-prologue',
68
+ conclusion: 'doc-conclusion',
69
+ appendix: 'doc-appendix',
70
+ afterword: 'doc-afterword',
71
+ acknowledgements: 'doc-acknowledgments', // ARIA uses US spelling (no "e")
72
+ footnote: 'doc-footnote',
73
+ footnotes: 'doc-endnotes',
74
+ };
75
+
76
+ // Accessibility metadata for the OPF.
77
+ //
78
+ // The `schema:*` rows mirror the keys in pandoc-epub.yml. Pandoc DOES emit
79
+ // those keys (despite the older comment here), so this array also acts as the
80
+ // dedup baseline: transformOpf skips any [prop, value] pair pandoc already
81
+ // wrote, which is why these strings must stay byte-for-byte in sync with
82
+ // pandoc-epub.yml — a drift (e.g. WCAG 2.0 vs 2.1 summary) produces duplicate
83
+ // <meta> elements rather than a replacement.
84
+ //
85
+ // The `dcterms:conformsTo` and `a11y:*` rows are EPUB Accessibility 1.1
86
+ // conformance reporting (https://www.w3.org/TR/epub-a11y-11/#sec-conf-reporting).
87
+ // Pandoc emits NEITHER, so they are added here unconditionally. The conformsTo
88
+ // string format is spec-mandated: "EPUB Accessibility <ver> - WCAG <ver> Level
89
+ // <A|AA|AAA>". certifiedBy is required (cardinality 1+) and refines the
90
+ // conformsTo declaration; for self-certification it names the publisher.
91
+ // Amazon's Kindle panel shows features + hazards only and never renders the
92
+ // conformance/certifier rows, but Apple Books, Kobo, Google Play, and the
93
+ // IngramSpark ONIX feed do surface them — so a complete claim matters there.
94
+ //
95
+ // Optional third element is an extra attribute string for the <meta> element.
96
+ // NOTE: schema:accessibilitySummary is handled separately (localizeSummary
97
+ // below) because its text is per-language; the rows here are language-neutral.
98
+ const config = loadConfigOrDefaults();
99
+ const a11y = config.accessibility;
100
+ const A11Y_META = [
101
+ ...a11y.access_modes.map((v) => ['schema:accessMode', v]),
102
+ ...a11y.access_mode_sufficient.map((v) => ['schema:accessModeSufficient', v]),
103
+ ...a11y.features.map((v) => ['schema:accessibilityFeature', v]),
104
+ ...a11y.hazards.map((v) => ['schema:accessibilityHazard', v]),
105
+ // The conformance claim and who certifies it are opt-in (book.config.yml).
106
+ ...(a11y.conforms_to ? [['dcterms:conformsTo', a11y.conforms_to, 'id="a11y-conformance"']] : []),
107
+ ...(a11y.conforms_to && a11y.certified_by ? [['a11y:certifiedBy', a11y.certified_by, 'refines="#a11y-conformance"']] : []),
108
+ ];
109
+
110
+ // a11y:certifierReport points to the public accessibility statement backing the
111
+ // conformance claim (the "compliance web page"). Per EPUB Accessibility 1.1
112
+ // (https://www.w3.org/TR/epub-a11y-11/#sec-conf-reporting) it is the ONE
113
+ // certifier property expressed through a <link rel=…> element, not a <meta> —
114
+ // its value is always a URL. Emitting it as a <meta property> triggers
115
+ // EPUBCheck OPF-027 ("Undefined property"). It refines the same conformsTo id.
116
+ const A11Y_CERTIFIER_REPORT_URL = a11y.conforms_to ? a11y.conformance_url : '';
117
+
118
+ // schema:accessibilitySummary, localized by publication language. Translated
119
+ // editions must carry a summary in their own language (EU Accessibility Act /
120
+ // EPUB Accessibility best practice). Keyed by primary language subtag, matched
121
+ // against the OPF's dc:language (e.g. "pt-BR" → "pt"); unknown languages fall
122
+ // back to English. The conformsTo string stays the spec-defined English
123
+ // identifier — it is not localized.
124
+ //
125
+ // The translations come from accessibility.summary_translations in
126
+ // book.config.yml (the kit ships drafts for pt, es, and de).
127
+ const ACCESSIBILITY_SUMMARY = { ...(a11y.summary_translations ?? {}), en: a11y.summary };
128
+
129
+ /** Pick the accessibility summary for a dc:language value (e.g. "pt-BR"). */
130
+ // The built-in translations describe a WCAG 2.1 AA conformance claim, so they
131
+ // apply only when the book makes one (accessibility.conforms_to). Returns ''
132
+ // when there's no summary to emit.
133
+ function summaryForLang(lang) {
134
+ if (!a11y.summary) return '';
135
+ const base = String(lang || 'en').split('-')[0].toLowerCase();
136
+ if (base === 'en' || !a11y.conforms_to) return a11y.summary;
137
+ return ACCESSIBILITY_SUMMARY[base] || a11y.summary;
138
+ }
139
+
140
+ const HEADING_RE = /^h[1-6]$/i;
141
+
142
+ // Stable anchor for the Kindle "Start Reading" target (structure.start_reading,
143
+ // or the first non-structural file in index.yml). Anchoring on an id rather
144
+ // than a filename survives chapter renumbering: pandoc's chNNN.xhtml names
145
+ // shift when index.yml order changes; the heading id doesn't.
146
+ function startReadingAnchor() {
147
+ if (config.structure.start_reading) return config.structure.start_reading;
148
+ if (!config.root) return null;
149
+ try {
150
+ const index = yaml.load(readFileSync(path.join(config.root, config.paths.index), 'utf8'));
151
+ const first = (index['input-files'] || [])
152
+ .map((f) => path.basename(f, '.md'))
153
+ .find((slug) => !isStructuralSlug(slug, config));
154
+ return first ?? null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+ const START_READING_ANCHOR = startReadingAnchor();
160
+
161
+ function escapeXml(s) {
162
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
163
+ }
164
+
165
+ /** Transform a single opening tag string like `<section ... epub:type="x">`. */
166
+ function transformTag(tag) {
167
+ const nameMatch = tag.match(/^<\s*([a-zA-Z][\w:-]*)/);
168
+ if (!nameMatch) return tag;
169
+ const name = nameMatch[1];
170
+
171
+ // Decorative callout-label emoji (Kindle): the 💡/👔/💻/🤔 glyph that the
172
+ // build composites in front of a callout's injected text label ("Pro tip:",
173
+ // "For managers:", …). The label already names the callout, so the image is
174
+ // redundant decoration — give it an empty alt + presentation role so screen
175
+ // readers skip it instead of announcing "light bulb Pro tip". Only targets
176
+ // `callout-emoji`; inline body emoji keep their descriptive alt (they convey
177
+ // authored content). Idempotent.
178
+ if (name.toLowerCase() === 'img' && /\sclass="[^"]*\bcallout-emoji\b[^"]*"/.test(tag)) {
179
+ let out = /\salt="/.test(tag)
180
+ ? tag.replace(/\salt="[^"]*"/, ' alt=""')
181
+ : tag.replace(/\s*\/?>$/, (end) => ` alt=""${end}`);
182
+ if (!/\srole="/.test(out)) {
183
+ out = out.replace(/\s*\/?>$/, (end) => ` role="presentation"${end}`);
184
+ }
185
+ return out;
186
+ }
187
+
188
+ // Decorative QR-code SVG (qrcode-generator output, identified by shape-rendering="crispEdges",
189
+ // which QR renders use for sharp module edges and the cover SVG does not).
190
+ // The same destination is a text link immediately after the code, so the QR
191
+ // conveys nothing a screen-reader user can't already reach. Left un-named it
192
+ // is a WCAG 2.1 SC 1.1.1 (Non-text Content) failure, which would undercut a
193
+ // book's "WCAG 2.1 Level AA" conformance claim; aria-hidden drops the
194
+ // graphic from the accessibility tree. NOTE: on an
195
+ // <svg> element EPUBCheck (RSC-005) only permits role=application|document|
196
+ // img, so we must NOT add role="presentation" here — aria-hidden alone is
197
+ // valid and sufficient for a decorative graphic. The cover SVG is untouched:
198
+ // it carries its accessible name via the parent <div role="img" aria-label>.
199
+ // Idempotent.
200
+ if (
201
+ name.toLowerCase() === 'svg' &&
202
+ /\sshape-rendering="crispEdges"/.test(tag) &&
203
+ !/\saria-hidden=/.test(tag)
204
+ ) {
205
+ return tag.replace(/\s*\/?>$/, (end) => ` aria-hidden="true"${end}`);
206
+ }
207
+
208
+ const epubType = tag.match(/\sepub:type="([^"]*)"/);
209
+
210
+ // Headings: drop epub:type (and any doc-* role) — invalid per EPUBCheck.
211
+ if (HEADING_RE.test(name)) {
212
+ if (!epubType) return tag;
213
+ return tag
214
+ .replace(/\sepub:type="[^"]*"/, '')
215
+ .replace(/\srole="doc-[^"]*"/, '');
216
+ }
217
+
218
+ if (!epubType) return tag;
219
+ if (/\srole="/.test(tag)) return tag; // already has a role — leave it
220
+ // Pick the role from the map valid for this element: links (<a>) only take
221
+ // link-type doc roles; everything else takes structural roles.
222
+ const map = name.toLowerCase() === 'a' ? LINK_ROLE_MAP : STRUCT_ROLE_MAP;
223
+ const role = map[epubType[1]];
224
+ if (!role) return tag; // no matching DPUB role for this epub:type on this element
225
+
226
+ // Insert role immediately before the closing `>` (handles self-closing too).
227
+ return tag.replace(/\s*\/?>$/, (end) => ` role="${role}"${end}`);
228
+ }
229
+
230
+ function transformXhtml(content) {
231
+ // Match opening/self-closing tags only (not closing tags), leaving text
232
+ // nodes untouched.
233
+ return content.replace(/<\s*[a-zA-Z][^>]*>/g, (tag) =>
234
+ tag.startsWith('</') ? tag : transformTag(tag)
235
+ );
236
+ }
237
+
238
+ // Screen-reader-only inline style (mirrors the theme's clip technique):
239
+ // keeps the element in the accessibility tree and reading order while removing
240
+ // it visually. Used for headings that must exist for heading-order validity but
241
+ // shouldn't render.
242
+ const SR_ONLY =
243
+ 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0';
244
+
245
+ // Pandoc's EPUB writer puts the front matter that precedes the first
246
+ // chapter-level heading (the copyright page and dedication, ahead of the
247
+ // first chapter) into a synthetic chapter and borrows the book title for its
248
+ // <h1>. The HTML/PDF writers don't split into chapters, so they never generate
249
+ // it — which is why the print front matter flows cleanly but the EPUB shows a
250
+ // redundant book-title heading and crams copyright + dedication onto one
251
+ // reflow screen. Both fixes are EPUB-only; the print path is untouched.
252
+ function fixFrontMatter(content) {
253
+ // Scope strictly to the document containing the copyright page.
254
+ if (!content.includes('id="copyright-page"')) return content;
255
+ let out = content;
256
+
257
+ // 1. Collapse the synthetic title heading to screen-reader-only. Keeping it
258
+ // (rather than deleting) preserves a valid h1 → h2 (Copyright) → h3 order
259
+ // and the section's heading for a11y; it just stops rendering
260
+ // as a duplicate of the title page.
261
+ out = out.replace(
262
+ /(<section\b[^>]*\bclass="[^"]*\blevel1\b[^"]*"[^>]*>\s*)<h1\b([^>]*)>([\s\S]*?)<\/h1>/,
263
+ (m, open, attrs, text) =>
264
+ /\bstyle=/.test(attrs) ? m : `${open}<h1${attrs} style="${SR_ONLY}">${text}</h1>`
265
+ );
266
+
267
+ // 2. Start the dedication on its own screen, mirroring the PDF's discrete
268
+ // copyright and dedication pages.
269
+ out = out.replace(
270
+ /<div class="dedication"(?![^>]*\bstyle=)/,
271
+ '<div class="dedication" style="page-break-before: always"'
272
+ );
273
+
274
+ return out;
275
+ }
276
+
277
+ // Pandoc emits end-of-section footnotes as <aside epub:type="footnote"
278
+ // id="fnN"> containing bare <p> text — with no visible number and no
279
+ // backlink. EPUB3 reading systems that render footnotes as popups (Apple
280
+ // Books, Kobo) paper over this, but Kindle lists the asides inline with
281
+ // nothing to tie each note to its reference. Inject the note number (which
282
+ // matches the inline marker pandoc already prints) as a backlink to its
283
+ // #fnrefN anchor at the start of the aside's first paragraph. Footnote ids
284
+ // reset per chapter and are always numeric (fn1, fn2, …), so the id suffix
285
+ // is exactly the displayed number. Idempotent via the negative lookahead.
286
+ function fixFootnoteNumbers(content) {
287
+ if (!content.includes('epub:type="footnote"')) return content;
288
+ return content.replace(
289
+ /(<aside\b[^>]*\bepub:type="footnote"[^>]*\bid="fn([\w-]+)"[^>]*>\s*<p\b[^>]*>)(?!\s*<a\b[^>]*class="footnote-back")/g,
290
+ (m, open, id) =>
291
+ `${open}<a href="#fnref${id}" class="footnote-back" role="doc-backlink">${escapeXml(id)}.</a> `
292
+ );
293
+ }
294
+
295
+ // Relabel the synthetic front-matter entry in the nav TOC (book title →
296
+ // strings.copyright_nav, "Copyright" by default) so the table of contents doesn't list a redundant title link
297
+ // pointing at the copyright page. The TOC's own <h1 id="toc-title"> is not an
298
+ // <a>, so it is untouched.
299
+ function fixNav(content, title) {
300
+ if (!title || !/epub:type="toc"/.test(content)) return content;
301
+ const esc = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
302
+ const label = escapeXml(config.strings?.copyright_nav ?? 'Copyright');
303
+ return content.replace(new RegExp(`(<a href="[^"]+#[^"]+">)${esc}(</a>)`), (m, open, close) => `${open}${label}${close}`);
304
+ }
305
+
306
+ // Find the content file (relative to EPUB/) that holds START_READING_ANCHOR.
307
+ // Returns e.g. "text/ch002.xhtml", or null if no file matches.
308
+ function findStartHref(workDir) {
309
+ const textDir = path.join(workDir, 'EPUB', 'text');
310
+ if (!START_READING_ANCHOR || !existsSync(textDir)) return null;
311
+ const re = new RegExp(`id="${START_READING_ANCHOR}"`);
312
+ for (const entry of readdirSync(textDir)) {
313
+ if (!/\.(xhtml|html)$/.test(entry)) continue;
314
+ if (re.test(readFileSync(path.join(textDir, entry), 'utf8'))) {
315
+ return `text/${entry}`;
316
+ }
317
+ }
318
+ return null;
319
+ }
320
+
321
+ // Add an `epub:type="bodymatter"` entry to the landmarks nav so Kindle has a
322
+ // "Start Reading" target. Scoped to the landmarks <nav> (its first </ol>), so
323
+ // the visible toc nav is untouched. Idempotent via the bodymatter check.
324
+ function addStartReadingLandmark(content, startHref) {
325
+ if (!startHref) return content;
326
+ if (!/epub:type="landmarks"/.test(content)) return content;
327
+ if (/epub:type="bodymatter"/.test(content)) return content;
328
+ return content.replace(
329
+ /(<nav\b[^>]*\bepub:type="landmarks"[\s\S]*?)(\n?\s*<\/ol>)/,
330
+ `$1\n <li><a href="${startHref}" epub:type="bodymatter">Beginning</a></li>$2`
331
+ );
332
+ }
333
+
334
+ function transformOpf(content, startHref) {
335
+ let opf = content;
336
+
337
+ // 1. Package language.
338
+ const langMatch = opf.match(/<dc:language>([^<]+)<\/dc:language>/);
339
+ const lang = langMatch ? langMatch[1].trim() : 'en-US';
340
+ if (!/<package[^>]*\sxml:lang=/.test(opf)) {
341
+ opf = opf.replace(/<package(\s)/, `<package xml:lang="${lang}"$1`);
342
+ }
343
+
344
+ // 1b. Declare the `a11y` package prefix used by the EPUB Accessibility 1.1
345
+ // certifier metadata below. It is a reserved prefix in EPUB 3.3 (no
346
+ // declaration strictly required), but older EPUBCheck builds — e.g. the
347
+ // apt package on the CI runner — flag it as undeclared, so we declare it
348
+ // explicitly with its canonical URI. `dcterms` and `schema` are
349
+ // universally reserved and need no declaration. Idempotent.
350
+ if (!/\sprefix="[^"]*\ba11y:/.test(opf)) {
351
+ const A11Y_PREFIX = 'a11y: http://www.idpf.org/epub/vocab/package/a11y/#';
352
+ if (/<package[^>]*\sprefix="/.test(opf)) {
353
+ opf = opf.replace(/(<package[^>]*\sprefix=")/, `$1${A11Y_PREFIX} `);
354
+ } else {
355
+ opf = opf.replace(/<package(\s)/, `<package prefix="${A11Y_PREFIX}"$1`);
356
+ }
357
+ }
358
+
359
+ // 2. schema.org accessibility metadata (skip whatever already exists).
360
+ const additions = A11Y_META.filter(
361
+ ([prop, val]) =>
362
+ !new RegExp(`property="${prop}"[^>]*>${escapeXml(val).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}<`).test(opf)
363
+ )
364
+ .map(
365
+ ([prop, val, attrs]) =>
366
+ ` <meta property="${prop}"${attrs ? ` ${attrs}` : ''}>${escapeXml(val)}</meta>`
367
+ )
368
+ .join('\n');
369
+ if (additions) {
370
+ opf = opf.replace(/([ \t]*)<\/metadata>/, `${additions}\n$1</metadata>`);
371
+ }
372
+
373
+ // 2a. a11y:certifierReport — a <link>, not a <meta> (see note above). Injected
374
+ // separately with a rel-based idempotency guard.
375
+ if (A11Y_CERTIFIER_REPORT_URL && !/\srel="a11y:certifierReport"/.test(opf)) {
376
+ opf = opf.replace(
377
+ /([ \t]*)<\/metadata>/,
378
+ ` <link rel="a11y:certifierReport" refines="#a11y-conformance" href="${A11Y_CERTIFIER_REPORT_URL}"/>\n$1</metadata>`
379
+ );
380
+ }
381
+
382
+ // 2b. Localized accessibility summary. Pandoc emits an English summary from
383
+ // pandoc-epub.yml for every build (including translated ones), so remove
384
+ // any existing summary and inject the one matching dc:language. Authoritative
385
+ // remove-then-add keeps exactly one summary, in the right language.
386
+ const summary = summaryForLang(lang);
387
+ opf = opf.replace(/[ \t]*<meta\b[^>]*\bproperty="schema:accessibilitySummary"[^>]*>[\s\S]*?<\/meta>\s*\n?/g, '');
388
+ if (summary) {
389
+ opf = opf.replace(
390
+ /([ \t]*)<\/metadata>/,
391
+ ` <meta property="schema:accessibilitySummary">${escapeXml(summary)}</meta>\n$1</metadata>`
392
+ );
393
+ }
394
+
395
+ // 3. "Start Reading" guide reference (legacy EPUB2 guide, still read by
396
+ // Kindle). pandoc emits type="toc"/"cover" but never type="text".
397
+ if (startHref && /<guide\b/.test(opf) && !/<reference\b[^>]*\btype="text"/.test(opf)) {
398
+ opf = opf.replace(
399
+ /([ \t]*)<\/guide>/,
400
+ ` <reference type="text" title="Beginning" href="${startHref}" />\n$1</guide>`
401
+ );
402
+ }
403
+
404
+ // 4. Cover as cover art, not the first reading page. pandoc emits the cover's
405
+ // spine itemref with the default linear="yes", so Kindle treats the cover
406
+ // as page one and never honors the Start Reading Location. Marking it
407
+ // linear="no" tells readers the cover is out of the linear reading order;
408
+ // the cover-image metadata still shows it as the book's cover.
409
+ opf = opf.replace(
410
+ /(<itemref\b[^>]*\bidref="cover[^"]*")([^>]*?)(\s*\/?>)/,
411
+ (full, head, mid, tail) =>
412
+ /\blinear=/.test(head + mid)
413
+ ? `${head}${mid}${tail}`.replace(/\blinear="[^"]*"/, 'linear="no"')
414
+ : `${head}${mid} linear="no"${tail}`
415
+ );
416
+
417
+ return opf;
418
+ }
419
+
420
+ // --- unpack + transform + repackage -----------------------------------------
421
+ // Every file under dir, as sorted '/'-separated relative paths.
422
+ function listFiles(dir, prefix = '') {
423
+ const out = [];
424
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
425
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
426
+ if (entry.isDirectory()) out.push(...listFiles(path.join(dir, entry.name), rel));
427
+ else out.push(rel);
428
+ }
429
+ return out.sort();
430
+ }
431
+
432
+ // The time stored on every entry: SOURCE_DATE_EPOCH when the build sets one,
433
+ // else 1980-01-01, the earliest time a zip entry can hold.
434
+ function zipTimestamp() {
435
+ const epoch = Number(process.env.SOURCE_DATE_EPOCH);
436
+ return new Date(Number.isFinite(epoch) && epoch > 0 ? epoch * 1000 : Date.UTC(1980, 0, 1));
437
+ }
438
+
439
+ function main(epubPath) {
440
+ const workDir = mkdtempSync(path.join(tmpdir(), 'epub-pp-'));
441
+ try {
442
+ execFileSync('unzip', ['-q', '-o', path.resolve(epubPath), '-d', workDir]);
443
+
444
+ // Book title (for the nav relabel below), read before transforming the OPF.
445
+ const opfEarly = existsSync(path.join(workDir, 'EPUB', 'content.opf'))
446
+ ? readFileSync(path.join(workDir, 'EPUB', 'content.opf'), 'utf8')
447
+ : '';
448
+ const titleMatch = opfEarly.match(/<dc:title[^>]*>([^<]+)<\/dc:title>/);
449
+ const bookTitle = titleMatch ? titleMatch[1].trim() : null;
450
+
451
+ // Locate the "Start Reading" target: the file holding START_READING_ANCHOR
452
+ // (structure.start_reading, or the first narrative chapter). Searched once, by
453
+ // content, so it survives chapter renumbering.
454
+ const startHref = findStartHref(workDir);
455
+ if (!startHref) {
456
+ console.warn(
457
+ `⚠ start-reading anchor "${START_READING_ANCHOR}" not found — "Start Reading" marker not added`
458
+ );
459
+ }
460
+
461
+ // --- transform content docs + nav ----------------------------------------
462
+ let xhtmlCount = 0;
463
+ const walk = (dir) => {
464
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
465
+ const full = path.join(dir, entry.name);
466
+ if (entry.isDirectory()) walk(full);
467
+ else if (/\.(xhtml|html)$/.test(entry.name)) {
468
+ const before = readFileSync(full, 'utf8');
469
+ let after = transformXhtml(before);
470
+ after = fixFootnoteNumbers(after);
471
+ after = fixFrontMatter(after);
472
+ after = fixNav(after, bookTitle);
473
+ after = addStartReadingLandmark(after, startHref);
474
+ if (after !== before) {
475
+ writeFileSync(full, after);
476
+ xhtmlCount += 1;
477
+ }
478
+ }
479
+ }
480
+ };
481
+ walk(workDir);
482
+
483
+ // --- transform content.opf -----------------------------------------------
484
+ const opfPath = path.join(workDir, 'EPUB', 'content.opf');
485
+ if (existsSync(opfPath)) {
486
+ writeFileSync(opfPath, transformOpf(readFileSync(opfPath, 'utf8'), startHref));
487
+ } else {
488
+ console.error('✗ EPUB/content.opf not found — unexpected EPUB layout');
489
+ process.exit(1);
490
+ }
491
+
492
+ // --- repackage (mimetype first, stored; everything else deflated) --------
493
+ // The same content must give the same bytes: entries in sorted order, no
494
+ // directory entries, and every file's time and mode fixed. Otherwise zip
495
+ // records readdir order, the umask, and when each file was unpacked or
496
+ // rewritten (in local time), so two builds of an unchanged book differ.
497
+ const files = listFiles(workDir).filter((f) => f !== 'mimetype');
498
+ const stamp = zipTimestamp();
499
+ for (const f of ['mimetype', ...files]) {
500
+ const full = path.join(workDir, f);
501
+ chmodSync(full, 0o644);
502
+ utimesSync(full, stamp, stamp);
503
+ }
504
+ const outAbs = path.resolve(epubPath);
505
+ rmSync(outAbs, { force: true });
506
+ const zipOpts = { cwd: workDir, env: { ...process.env, TZ: 'UTC' } };
507
+ execFileSync('zip', ['-X', '-0', outAbs, 'mimetype'], zipOpts);
508
+ execFileSync('zip', ['-X', '-D', '-9', outAbs, '-@'], { ...zipOpts, input: files.join('\n') + '\n' });
509
+
510
+ console.log(
511
+ `✓ post-processed ${epubPath}: a11y metadata + xml:lang in OPF, roles added to ${xhtmlCount} document(s)`
512
+ );
513
+ } finally {
514
+ rmSync(workDir, { recursive: true, force: true });
515
+ }
516
+ }
517
+
518
+ // Exported for unit testing (pure string transforms).
519
+ export {
520
+ fixFootnoteNumbers,
521
+ transformXhtml,
522
+ escapeXml,
523
+ addStartReadingLandmark,
524
+ transformOpf,
525
+ listFiles,
526
+ main,
527
+ };
528
+
529
+ // Run only when invoked directly as a CLI (not when imported by tests).
530
+ if (isMain(import.meta.url)) {
531
+ const epubPath = process.argv[2];
532
+ if (!epubPath) {
533
+ console.error('Usage: book-kit postprocess-epub <path-to.epub>');
534
+ process.exit(2);
535
+ }
536
+ if (!existsSync(epubPath)) {
537
+ console.error(`✗ ${epubPath}: not found`);
538
+ process.exit(2);
539
+ }
540
+ main(epubPath);
541
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Stage a print copy of the book's source for the PDF and Kindle builds.
5
+ *
6
+ * Print readers can't click, so links become footnotes (links-to-footnotes).
7
+ * That rewrite runs on a copy, never on src/:
8
+ *
9
+ * 1. Copy paths.src to dist/.book-kit/print-src/.
10
+ * 2. Convert links to footnotes there. With --link-footnote-urls (Kindle),
11
+ * each footnote's URL stays a tappable link.
12
+ * 3. Write dist/.book-kit/print-index.yml: index.yml pointed at the copy,
13
+ * minus build.print_exclude files. Marker comments listed in
14
+ * build.print_inject become those files with --inject (PDF) and are
15
+ * dropped without it (Kindle).
16
+ *
17
+ * Usage: book-kit prep-print-src [--preserve-internal-links] [--link-footnote-urls] [--inject]
18
+ * Prints the path of the generated index file.
19
+ */
20
+
21
+ import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { execFileSync } from 'node:child_process';
23
+ import { join, relative } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { loadConfig, pathFor } from '../lib/config.js';
26
+ import { isMain } from '../lib/is-main.js';
27
+
28
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+
30
+ export function printIndex(indexText, { srcRel, printRel, exclude = [], inject = {}, includeInjected = false }) {
31
+ const srcPrefix = new RegExp(escapeRegExp(`${srcRel}/`), 'g');
32
+ const lines = [];
33
+ for (const line of indexText.split('\n')) {
34
+ const marker = Object.keys(inject).find((m) => line.includes(m));
35
+ if (marker) {
36
+ if (includeInjected) lines.push(` - ${printRel}/${inject[marker]}`);
37
+ continue;
38
+ }
39
+ if (exclude.some((f) => line.includes(f.replace(/\.md$/, '')))) continue;
40
+ lines.push(line.replace(srcPrefix, `${printRel}/`));
41
+ }
42
+ return lines.join('\n');
43
+ }
44
+
45
+ function main(argv) {
46
+ const config = loadConfig();
47
+ const preserveInternal = argv.includes('--preserve-internal-links');
48
+ const includeInjected = argv.includes('--inject');
49
+ const linkFootnoteUrls = argv.includes('--link-footnote-urls');
50
+
51
+ const src = pathFor('src', config);
52
+ const work = join(pathFor('dist', config), '.book-kit');
53
+ const printSrc = join(work, 'print-src');
54
+ rmSync(printSrc, { recursive: true, force: true });
55
+ mkdirSync(work, { recursive: true });
56
+ cpSync(src, printSrc, { recursive: true });
57
+
58
+ const l2f = fileURLToPath(new URL('./links-to-footnotes.js', import.meta.url));
59
+ execFileSync('node', [l2f, printSrc, '--no-backup', ...(preserveInternal ? ['--preserve-internal-links'] : []), ...(linkFootnoteUrls ? ['--link-footnote-urls'] : [])], {
60
+ stdio: ['ignore', process.stderr, 'inherit'],
61
+ });
62
+
63
+ const indexOut = join(work, 'print-index.yml');
64
+ const text = printIndex(readFileSync(pathFor('index', config), 'utf8'), {
65
+ srcRel: relative(config.root, src),
66
+ printRel: relative(config.root, printSrc),
67
+ exclude: config.build.print_exclude,
68
+ inject: config.build.print_inject,
69
+ includeInjected,
70
+ });
71
+ writeFileSync(indexOut, text);
72
+ console.log(indexOut);
73
+ }
74
+
75
+ if (isMain(import.meta.url)) {
76
+ main(process.argv.slice(2));
77
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ // book-kit prose [textlint options] [files]
4
+ //
5
+ // Prose lint with textlint and the book's resolved config (lib/lint.js): the
6
+ // kit's base rules, lint.textlint, and the kit and preset rules lint.rules
7
+ // turns on. Files default to every chapter in paths.src. On GitHub Actions,
8
+ // findings are reported as annotations on each file and line.
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import { readdirSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { kitPath, loadConfig } from '../lib/config.js';
14
+ import { namesFiles, prepareTextlint } from '../lib/lint.js';
15
+
16
+ const config = loadConfig();
17
+ const { config: textlintrc, rulesdir } = prepareTextlint(config);
18
+
19
+ // Arguments pass through in order; chapters are added only when none names files.
20
+ const args = process.argv.slice(2);
21
+ const chapters = () =>
22
+ readdirSync(join(config.root, config.paths.src))
23
+ .filter((f) => f.endsWith('.md'))
24
+ .sort()
25
+ .map((f) => join(config.paths.src, f));
26
+
27
+ const format =
28
+ process.env.GITHUB_ACTIONS === 'true' && !args.some((o) => o.startsWith('--format') || o === '-f')
29
+ ? ['--format', kitPath('lib', 'textlint-github-formatter.js')]
30
+ : [];
31
+
32
+ const result = spawnSync(
33
+ 'npx',
34
+ ['textlint', '--config', textlintrc, '--rulesdir', rulesdir, ...format, ...args, ...(namesFiles(args, config.root) ? [] : chapters())],
35
+ { stdio: 'inherit', cwd: config.root },
36
+ );
37
+ if (result.error) throw result.error;
38
+ process.exit(result.status ?? 1);