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.
- package/LICENSE +21 -0
- package/NOTICE.md +72 -0
- package/README.md +251 -2
- package/bin/book-kit.js +115 -0
- package/commands/_emoji_io.py +32 -0
- package/commands/audit-chapter-lengths.js +244 -0
- package/commands/check-epub-size.js +68 -0
- package/commands/check-pdf-page-count.js +162 -0
- package/commands/composite-kindle-emoji.py +85 -0
- package/commands/composite-print-emoji.py +98 -0
- package/commands/conventions.js +76 -0
- package/commands/convert-pdfx.sh +194 -0
- package/commands/download-icc-profile.sh +105 -0
- package/commands/fonts.py +212 -0
- package/commands/generate-reference-docx.py +344 -0
- package/commands/kindle-fonts.js +173 -0
- package/commands/links-to-footnotes.js +366 -0
- package/commands/markdownlint.js +25 -0
- package/commands/minify-css.js +43 -0
- package/commands/optimize-images.js +73 -0
- package/commands/parity.sh +150 -0
- package/commands/postprocess-epub.js +541 -0
- package/commands/prep-print-src.js +77 -0
- package/commands/prose.js +38 -0
- package/commands/release-files.js +89 -0
- package/commands/reorder-print-frontmatter.js +79 -0
- package/commands/strip-page-rules.js +1168 -0
- package/commands/strip-property-rules.js +93 -0
- package/commands/update-key-takeaways.js +314 -0
- package/commands/validate.js +283 -0
- package/configs/markdownlint-cli2.jsonc +98 -0
- package/configs/textlintrc.json +22 -0
- package/css/theme/01-tokens.css +83 -0
- package/css/theme/02-fonts.css +79 -0
- package/css/theme/03-prose.css +53 -0
- package/css/theme/04-base.css +226 -0
- package/css/theme/05-components.css +546 -0
- package/css/theme/06-utilities.css +40 -0
- package/css/theme/07-ereader.css +245 -0
- package/css/theme/08-epub.css +865 -0
- package/css/theme/09-back-matter.css +47 -0
- package/css/theme/10-dark.css +302 -0
- package/css/theme/11-print.css +1229 -0
- package/css/theme.css +38 -0
- package/defaults/book.config.yml +318 -0
- package/defaults/pandoc-base.yml +19 -0
- package/defaults/pandoc-docx.yml +6 -0
- package/defaults/pandoc-epub-fonts.yml +6 -0
- package/defaults/pandoc-epub.yml +15 -0
- package/defaults/pandoc-kindle.yml +6 -0
- package/emoji/1f382.png +0 -0
- package/emoji/1f389.png +0 -0
- package/emoji/1f440.png +0 -0
- package/emoji/1f44d.png +0 -0
- package/emoji/1f44f.png +0 -0
- package/emoji/1f454.png +0 -0
- package/emoji/1f4a1.png +0 -0
- package/emoji/1f4bb.png +0 -0
- package/emoji/1f602.png +0 -0
- package/emoji/1f603.png +0 -0
- package/emoji/1f604.png +0 -0
- package/emoji/1f60a.png +0 -0
- package/emoji/1f60f.png +0 -0
- package/emoji/1f64c.png +0 -0
- package/emoji/1f64f.png +0 -0
- package/emoji/1f914.png +0 -0
- package/emoji/1f923.png +0 -0
- package/emoji/2714.png +0 -0
- package/emoji/274c.png +0 -0
- package/filters/add-div-titles.lua +196 -0
- package/filters/arc-notice.lua +36 -0
- package/filters/body-emoji-images.lua +48 -0
- package/filters/callout-classes.lua +40 -0
- package/filters/lib/book_kit.lua +110 -0
- package/filters/revision.lua +17 -0
- package/filters/strip-comments.lua +40 -0
- package/filters/strip-emoji-kindle.lua +134 -0
- package/fonts/LICENSE-source-code-pro.md +93 -0
- package/fonts/LICENSE-source-sans.md +93 -0
- package/fonts/LICENSE-source-serif.md +93 -0
- package/fonts/recipe.json +41 -0
- package/just/book-kit.just +229 -0
- package/lib/acronyms.js +47 -0
- package/lib/callouts.js +17 -0
- package/lib/config.js +211 -0
- package/lib/is-main.js +21 -0
- package/lib/lint.js +148 -0
- package/lib/pandoc.js +148 -0
- package/lib/paragraphs.js +173 -0
- package/lib/revision.js +35 -0
- package/lib/structure.js +39 -0
- package/lib/textlint-github-formatter.js +58 -0
- package/lib/validate.js +202 -0
- package/package.json +98 -4
- package/presets/business-nonfiction/CONVENTIONS.md +35 -0
- package/presets/business-nonfiction/preset.yml +128 -0
- package/profiles/pdfx-def.ps +42 -0
- package/requirements.txt +14 -0
- package/rules/no-ai-like-patterns.js +209 -0
- package/rules/no-assumptive.js +76 -0
- package/rules/no-bare-acronyms.js +129 -0
- package/rules/no-cliches.js +544 -0
- package/rules/no-cultural-idioms.js +422 -0
- package/rules/no-vague-intensifiers.js +94 -0
- package/templates/template-epub.html +79 -0
- package/templates/template.html +73 -0
- package/toolchain.json +42 -0
- package/validators/acronym-expansion.js +94 -0
- package/validators/bold-keywords.js +56 -0
- package/validators/callout-balance.js +74 -0
- package/validators/callout-divs.js +66 -0
- package/validators/callout-labels.js +102 -0
- package/validators/chapter-lengths.js +14 -0
- package/validators/chapter.js +57 -0
- package/validators/cross-references.js +45 -0
- package/validators/doubled-words.js +76 -0
- package/validators/duplicate-headings.js +52 -0
- package/validators/em-dashes.js +73 -0
- package/validators/escaped-smallcaps.js +46 -0
- package/validators/footnotes.js +78 -0
- package/validators/heading-hierarchy.js +58 -0
- package/validators/hypothetical-hooks.js +103 -0
- package/validators/index-integrity.js +28 -0
- package/validators/index.js +73 -0
- package/validators/list-punctuation.js +123 -0
- package/validators/min-opener-length.js +87 -0
- package/validators/no-bare-url-link-text.js +38 -0
- package/validators/no-todo-markers.js +49 -0
- package/validators/no-transition-todos.js +34 -0
- package/validators/number-range-dashes.js +72 -0
- package/validators/paragraph-length.js +62 -0
- package/validators/quote-punctuation.js +85 -0
- package/validators/section-intro-final-lead.js +90 -0
- package/validators/section-intro-has-lead.js +41 -0
- package/validators/section-intro-no-tldr.js +37 -0
- package/validators/section-intros.js +33 -0
- package/validators/sentence-starters.js +132 -0
- package/validators/smallcaps.js +67 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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);
|