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,1168 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Strip unsupported CSS features from CSS for EPUB compatibility
5
+ * Uses PostCSS for proper CSS AST manipulation
6
+ *
7
+ * EPUB does not support:
8
+ * - CSS Paged Media Module features like @page rules with margin boxes
9
+ * - @layer directives
10
+ * - @supports feature queries
11
+ * - @property declarations (CSS Properties and Values API)
12
+ * - Modern pseudo-classes like :where() and :is()
13
+ * - ::before pseudo-elements with content (inconsistent support)
14
+ * - ::marker pseudo-elements (inconsistent support; bullets fall back to inherited text color)
15
+ * - Attribute selectors with ~= operator (e.g., [class~="value"])
16
+ * - Modern color functions like oklch() and color-mix()
17
+ * - CSS Nesting (nested rules must be flattened)
18
+ * - CSS Logical Properties (inconsistent support across e-readers)
19
+ * - @media screen rules (web-browser-specific; EPUB readers use global styles)
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+ import postcss from 'postcss';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { calloutSelectors, labelledSelectors } from '../lib/callouts.js';
26
+ import { isMain } from '../lib/is-main.js';
27
+
28
+ const __filename = fileURLToPath(import.meta.url);
29
+
30
+ // Callout selectors that have ::before rules to remove.
31
+ // PostCSS/Tailwind v4 normalizes ::before to :before (single colon) in minified
32
+ // output, so we match both forms to ensure removal regardless of the input format.
33
+ const CALLOUT_SELECTORS = [...labelledSelectors, '.footnotes'].flatMap((sel) => [
34
+ `${sel}::before`,
35
+ `${sel}:before`,
36
+ ]);
37
+
38
+ // Callout *box* classes (the boxes themselves, not their descendants like
39
+ // `.tldr p`). On reflowable readers, page-break-inside:avoid / break-inside:avoid
40
+ // on these boxes forces the entire callout onto the next screen when it won't
41
+ // fit, leaving a blank gap at the bottom of the prior screen. We strip those
42
+ // declarations for EPUB so callouts flow and split naturally. Print/PDF keeps
43
+ // them — WeasyPrint reads dist/style.css directly, not this stripped output.
44
+ // (Kindle needs more than this — a filled, bordered box still won't split there.
45
+ // commands/kindle-fonts.js drops the fill/box-decoration on the Kindle-only path.)
46
+ const CALLOUT_BOX_CLASSES = calloutSelectors;
47
+
48
+ /**
49
+ * True if a selector targets a callout box itself (any comma-separated part is
50
+ * a bare callout class or that class with a pseudo, e.g. `.tldr:first-child`),
51
+ * as opposed to a descendant such as `.tldr p`.
52
+ * @param {string} selector - CSS selector string
53
+ * @returns {boolean}
54
+ */
55
+ function targetsCalloutBox(selector) {
56
+ return selector.split(',').some((part) => {
57
+ const s = part.trim();
58
+ return CALLOUT_BOX_CLASSES.some((c) => s === c || s.startsWith(`${c}:`));
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Remove page-break-inside:avoid / break-inside:avoid from callout-box rules.
64
+ * Returns true if the declaration was removed.
65
+ * @param {import('postcss').Declaration} decl
66
+ * @returns {boolean}
67
+ */
68
+ function stripCalloutBreakAvoid(decl) {
69
+ if (
70
+ (decl.prop === 'break-inside' || decl.prop === 'page-break-inside') &&
71
+ /avoid/.test(decl.value) &&
72
+ decl.parent &&
73
+ decl.parent.type === 'rule' &&
74
+ targetsCalloutBox(decl.parent.selector)
75
+ ) {
76
+ decl.remove();
77
+ return true;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * Convert unsupported attribute selectors to simpler alternatives
84
+ * Specifically handles [class~="value"] (contains word selector)
85
+ * and removes case-sensitivity flags like [type="value" s]
86
+ * @param {string} selector - CSS selector string
87
+ * @returns {string} Converted selector
88
+ */
89
+ function convertAttributeSelectors(selector) {
90
+ // Convert [class~="value"], [class~='value'], [class~=value] to .value
91
+ // Tailwind v4 CLI minifies away quotes when class names are simple identifiers.
92
+ let result = selector.replace(/\[class~=(?:"([^"]+)"|'([^']+)'|([a-zA-Z0-9_-]+))\]/g, (match, dq, sq, bare) => {
93
+ const className = dq || sq || bare;
94
+ // Check if class name is safe (no special characters that would need escaping)
95
+ if (/^[a-zA-Z0-9_-]+$/.test(className)) {
96
+ return `.${className}`;
97
+ }
98
+ return match;
99
+ });
100
+
101
+ // Remove case-sensitivity flags from attribute selectors
102
+ // e.g., [type="i" s] → [type="i"]
103
+ result = result.replace(/\[([^\]]+)\s+[is]\]/g, '[$1]');
104
+
105
+ return result;
106
+ }
107
+
108
+ /**
109
+ * Simplify :not() selectors with multiple arguments
110
+ * EPUB doesn't support :not() with multiple comma-separated selectors
111
+ * @param {string} selector - CSS selector string
112
+ * @returns {string} Simplified selector
113
+ */
114
+ function simplifyNotSelector(selector) {
115
+ // Find :not(...) with multiple comma-separated selectors
116
+ // and keep only the first one
117
+ return selector.replace(/:not\(([^)]+)\)/g, (match, content) => {
118
+ const selectors = splitTopLevelCommas(content);
119
+ if (selectors.length > 1) {
120
+ // Keep only the first selector
121
+ return `:not(${selectors[0].trim()})`;
122
+ }
123
+ return match;
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Convert oklch() color to hex fallback
129
+ * oklch(L C H) where L=lightness%, C=chroma, H=hue degrees
130
+ * This is a simplified conversion that maps to approximate hex values
131
+ * @param {string} oklchStr - oklch() color string
132
+ * @returns {string} Hex color fallback
133
+ */
134
+ function oklchToHex(oklchStr) {
135
+ // Extract values from oklch(L% C H) or oklch(L C H)
136
+ const match = oklchStr.match(/oklch\(\s*([0-9.]+)(%)?\s+([0-9.]+)\s+([0-9.]+)\s*\)/);
137
+ if (!match) return '#000000'; // Fallback to black
138
+
139
+ const [, l, lUnit, c, h] = match;
140
+ const rawLightness = parseFloat(l);
141
+ // Normalize lightness:
142
+ // - If a % unit is present, treat value as 0–100%.
143
+ // - If unitless and <= 1, treat as 0–1 and scale to 0–100.
144
+ // - Otherwise assume it's already in 0–100 range.
145
+ const lightness =
146
+ lUnit === '%'
147
+ ? rawLightness
148
+ : (rawLightness <= 1 ? rawLightness * 100 : rawLightness);
149
+ const chroma = parseFloat(c);
150
+ const hue = parseFloat(h);
151
+
152
+ // Simplified conversion: map lightness to grayscale-ish values
153
+ // For EPUB, we prioritize readability over perfect color matching
154
+ if (chroma < 0.02) {
155
+ // Low chroma means it's basically grayscale
156
+ const gray = Math.round((lightness / 100) * 255);
157
+ return `#${gray.toString(16).padStart(2, '0').repeat(3)}`;
158
+ }
159
+
160
+ // For colors with chroma, map to common web colors based on hue
161
+ // Blue range (200-280 degrees)
162
+ if (hue >= 200 && hue < 280) {
163
+ if (lightness > 80) return '#e3f2fd';
164
+ if (lightness > 60) return '#2196f3';
165
+ if (lightness > 40) return '#1976d2';
166
+ return '#0d47a1';
167
+ }
168
+ // Green range (100-180 degrees)
169
+ if (hue >= 100 && hue < 180) {
170
+ if (lightness > 80) return '#e8f5e9';
171
+ if (lightness > 60) return '#4caf50';
172
+ if (lightness > 40) return '#388e3c';
173
+ return '#1b5e20';
174
+ }
175
+ // Red range (0-60 or 300-360 degrees)
176
+ if (hue < 60 || hue >= 300) {
177
+ if (lightness > 80) return '#ffebee';
178
+ if (lightness > 60) return '#f44336';
179
+ if (lightness > 40) return '#d32f2f';
180
+ return '#b71c1c';
181
+ }
182
+ // Yellow/Orange range (60-100 degrees)
183
+ if (hue >= 60 && hue < 100) {
184
+ if (lightness > 80) return '#fff8e1';
185
+ if (lightness > 60) return '#ffc107';
186
+ if (lightness > 40) return '#f57c00';
187
+ return '#e65100';
188
+ }
189
+
190
+ // Default: map lightness to grayscale
191
+ const gray = Math.round((lightness / 100) * 255);
192
+ return `#${gray.toString(16).padStart(2, '0').repeat(3)}`;
193
+ }
194
+
195
+ /**
196
+ * Convert color-mix() to static fallback color
197
+ * For simplicity, we'll use the first color in the mix
198
+ * @param {string} colorMixStr - color-mix() function string
199
+ * @returns {string} Fallback color
200
+ */
201
+ function colorMixToFallback(colorMixStr) {
202
+ // Extract the first color from color-mix(in colorspace, color1 percent%, color2)
203
+ const match = colorMixStr.match(/color-mix\([^,]+,\s*([^,]+?)\s+\d+%/);
204
+ if (match) {
205
+ const firstColor = match[1].trim();
206
+ // If it's an oklch color, convert it too
207
+ if (firstColor.startsWith('oklch(')) {
208
+ return oklchToHex(firstColor);
209
+ }
210
+ return firstColor;
211
+ }
212
+ return 'transparent'; // Fallback
213
+ }
214
+
215
+ /**
216
+ * Collect CSS custom property definitions from various sources
217
+ * Collects from :root, :host, @property declarations, and other common patterns
218
+ * @param {import('postcss').Root} root - PostCSS root
219
+ * @returns {Map<string, string>} Map of variable names to values
220
+ */
221
+ function collectCSSVariables(root) {
222
+ const variables = new Map();
223
+
224
+ // Collect initial-value from @property declarations (lowest priority)
225
+ // These provide fallback values for Tailwind's internal --tw-* variables
226
+ root.walkAtRules('property', atRule => {
227
+ const propName = atRule.params.trim();
228
+ atRule.walkDecls('initial-value', decl => {
229
+ variables.set(propName, decl.value);
230
+ });
231
+ });
232
+
233
+ // Selectors (or selector parts) that commonly define CSS variables.
234
+ // We split on commas so multi-selector rules like ":root,:host" or
235
+ // ":root, :host" (Tailwind v4 CLI emits the former) are matched regardless
236
+ // of whitespace.
237
+ const variableSelectorParts = new Set([':root', ':host', '.prose']);
238
+
239
+ root.walkRules(rule => {
240
+ const parts = rule.selector.split(',').map(s => s.trim());
241
+ if (parts.some(p => variableSelectorParts.has(p))) {
242
+ rule.walkDecls(decl => {
243
+ if (decl.prop.startsWith('--')) {
244
+ variables.set(decl.prop, decl.value);
245
+ }
246
+ });
247
+ }
248
+ });
249
+
250
+ return variables;
251
+ }
252
+
253
+ /**
254
+ * Resolve a single var() reference to its value
255
+ * Supports fallback values: var(--name, fallback)
256
+ * @param {string} varRef - The var() reference string
257
+ * @param {Map<string, string>} variables - Map of CSS variables
258
+ * @returns {string} Resolved value or original if not found
259
+ */
260
+ function resolveVarReference(varRef, variables) {
261
+ // Match var(--name) or var(--name, fallback)
262
+ const match = varRef.match(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\s*\)/);
263
+ if (!match) return varRef;
264
+
265
+ const [, varName, fallback] = match;
266
+
267
+ // Look up the variable
268
+ if (variables.has(varName)) {
269
+ let value = variables.get(varName);
270
+ // Recursively resolve if the value contains var() references
271
+ if (value.includes('var(')) {
272
+ value = resolveCSSVariables(value, variables);
273
+ }
274
+ return value;
275
+ }
276
+
277
+ // Use fallback if provided
278
+ if (fallback !== undefined) {
279
+ let resolvedFallback = fallback.trim();
280
+ // Resolve any var() in the fallback too
281
+ if (resolvedFallback.includes('var(')) {
282
+ resolvedFallback = resolveCSSVariables(resolvedFallback, variables);
283
+ }
284
+ return resolvedFallback;
285
+ }
286
+
287
+ // Return original if no match and no fallback
288
+ return varRef;
289
+ }
290
+
291
+ /**
292
+ * Resolve all CSS variable references in a value string
293
+ * Handles nested var() and fallback values
294
+ * @param {string} value - CSS value string
295
+ * @param {Map<string, string>} variables - Map of CSS variables
296
+ * @returns {string} Value with variables resolved
297
+ */
298
+
299
+ /**
300
+ * Regex pattern for matching CSS var() function with optional fallback
301
+ *
302
+ * Pattern breakdown:
303
+ * - var\( : Match 'var(' literally
304
+ * - \s* : Optional whitespace
305
+ * - (--[a-zA-Z0-9_-]+) : Capture group 1 - CSS variable name (--name-123)
306
+ * - \s* : Optional whitespace
307
+ * - (?:,\s* : Non-capturing group for optional fallback, starts with comma
308
+ * ([^()]* : Capture group 2 - fallback value (no parens, can be empty)
309
+ * (?:\([^)]*\) : Or content with balanced parens (for nested functions like rgb())
310
+ * [^()]*)* : More non-paren content
311
+ * )? : Entire comma+fallback group is optional
312
+ * \s*\) : Optional whitespace and closing paren
313
+ *
314
+ * Note: Capture group 2 must NOT be independently optional (?), otherwise
315
+ * JavaScript's regex engine skips it on empty fallbacks like var(--x,),
316
+ * returning undefined instead of "".
317
+ */
318
+ const CSS_VAR_PATTERN = /var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^()]*(?:\([^)]*\)[^()]*)*))\s*\)|var\(\s*(--[a-zA-Z0-9_-]+)\s*\)/g;
319
+
320
+ function resolveCSSVariables(value, variables) {
321
+ // Match var() including nested ones - process from innermost out
322
+ let result = value;
323
+ let prevResult = '';
324
+ let iterations = 0;
325
+ const maxIterations = 20; // Prevent infinite loops
326
+
327
+ // Keep resolving until no more var() references or max iterations
328
+ while (result.includes('var(') && result !== prevResult && iterations < maxIterations) {
329
+ prevResult = result;
330
+ // Replace var() references one at a time from the inside out
331
+ // Pattern uses alternation: group 1+2 for var(--name, fallback), group 3 for var(--name)
332
+ result = result.replace(CSS_VAR_PATTERN,
333
+ (match, varNameWithFallback, fallback, varNameWithout) => {
334
+ const varName = varNameWithFallback || varNameWithout;
335
+ if (variables.has(varName)) {
336
+ return variables.get(varName);
337
+ }
338
+ if (fallback !== undefined) {
339
+ return fallback.trim();
340
+ }
341
+ return match;
342
+ }
343
+ );
344
+ iterations++;
345
+ }
346
+
347
+ return result;
348
+ }
349
+
350
+ /**
351
+ * Mapping of CSS logical properties to their physical equivalents (LTR).
352
+ * EPUB readers have inconsistent support for CSS Logical Properties,
353
+ * so we convert them to physical properties for maximum compatibility.
354
+ */
355
+ const LOGICAL_PROPERTY_MAP = {
356
+ 'padding-inline-start': 'padding-left',
357
+ 'padding-inline-end': 'padding-right',
358
+ 'margin-inline-start': 'margin-left',
359
+ 'margin-inline-end': 'margin-right',
360
+ 'border-inline-start': 'border-left',
361
+ 'border-inline-end': 'border-right',
362
+ 'border-inline-start-width': 'border-left-width',
363
+ 'border-inline-end-width': 'border-right-width',
364
+ 'border-inline-start-style': 'border-left-style',
365
+ 'border-inline-end-style': 'border-right-style',
366
+ 'border-inline-start-color': 'border-left-color',
367
+ 'border-inline-end-color': 'border-right-color',
368
+ 'inset-inline-start': 'left',
369
+ 'inset-inline-end': 'right',
370
+ };
371
+
372
+ /**
373
+ * Strip Tailwind Typography's .prose-scoped rules from the CSS.
374
+ *
375
+ * Typography generates ~100 selectors like `.prose h1:not(.not-prose)` with
376
+ * specificity (0,2,1). After @layer stripping, these compete with our bare
377
+ * element selectors (0,0,1), causing cascade bugs. Removing them lets our
378
+ * @media not screen rules (now unwrapped to top level) win cleanly.
379
+ *
380
+ * For compound selectors (`.lead, .prose .lead:not(.not-prose)`), only the
381
+ * Typography-pattern parts are removed; bare selectors are preserved.
382
+ *
383
+ * @param {import('postcss').Root} root - PostCSS root
384
+ * @returns {number} Number of rules removed or simplified
385
+ */
386
+ function stripTypographyRules(root) {
387
+ let count = 0;
388
+ root.walkRules(rule => {
389
+ const parts = rule.selector.split(',').map(s => s.trim());
390
+ const hasTypographyParts = parts.some(p => p.includes(':not(.not-prose)'));
391
+ if (!hasTypographyParts) return;
392
+
393
+ const kept = parts.filter(p => !p.includes(':not(.not-prose)'));
394
+ if (kept.length === 0) {
395
+ rule.remove();
396
+ } else {
397
+ rule.selector = kept.join(', ');
398
+ }
399
+ count++;
400
+ });
401
+ return count;
402
+ }
403
+
404
+ /**
405
+ * PostCSS plugin to strip unsupported CSS features for EPUB
406
+ * @returns {import('postcss').Plugin}
407
+ */
408
+ function stripForEpubPlugin() {
409
+ return {
410
+ postcssPlugin: 'strip-for-epub',
411
+
412
+ // Process everything in Once hook to match sync version's behavior
413
+ Once(root) {
414
+ // First pass: Remove/unwrap at-rules (do this before collecting rules)
415
+ const nodesToRemove = [];
416
+ const nodesToUnwrap = [];
417
+
418
+ root.walkAtRules(atRule => {
419
+ if (atRule.name === 'supports' || atRule.name === 'page') {
420
+ nodesToRemove.push(atRule);
421
+ } else if (atRule.name === 'layer') {
422
+ if (atRule.nodes && atRule.nodes.length > 0) {
423
+ nodesToUnwrap.push(atRule);
424
+ } else {
425
+ nodesToRemove.push(atRule);
426
+ }
427
+ } else if (atRule.name === 'media' && atRule.params.trim() === 'print') {
428
+ // Remove @media print blocks — EPUB readers ignore print styles
429
+ nodesToRemove.push(atRule);
430
+ } else if (atRule.name === 'media' && atRule.parent && atRule.parent.type === 'rule') {
431
+ nodesToRemove.push(atRule);
432
+ } else if (atRule.name === 'media' && atRule.params && /^screen/.test(atRule.params.trim())) {
433
+ // Strip @media screen (and @media screen and ...) — these are web-browser-specific.
434
+ // EPUB readers use global styles; viewport units and flex within screen queries
435
+ // are unreliable in ebook readers (Kindle, Apple Books, Kobo, etc.).
436
+ nodesToRemove.push(atRule);
437
+ } else if (atRule.name === 'media' && atRule.params && /^not\s+screen$/.test(atRule.params.trim())) {
438
+ // Unwrap @media not screen — EPUB *is* "not screen", so these rules
439
+ // should apply unconditionally. Unwrapping avoids e-reader bugs where
440
+ // @media queries are unsupported or evaluated incorrectly.
441
+ if (atRule.nodes && atRule.nodes.length > 0) {
442
+ nodesToUnwrap.push(atRule);
443
+ } else {
444
+ nodesToRemove.push(atRule);
445
+ }
446
+ } else if (atRule.name === 'media' && atRule.params && /(min|max)-(width|height)/.test(atRule.params)) {
447
+ // Strip responsive media queries (e.g., @media (min-width: 40rem)).
448
+ // These come from Tailwind's responsive utility variants (.prose-lg, etc.)
449
+ // and target browser viewports. EPUB readers reflow content based on
450
+ // device/font settings, not viewport widths, so these are meaningless.
451
+ nodesToRemove.push(atRule);
452
+ }
453
+ });
454
+
455
+ nodesToRemove.forEach(node => node.remove());
456
+ nodesToUnwrap.forEach(node => node.replaceWith(node.nodes));
457
+
458
+ // Second pass: Flatten nested rules (CSS Nesting not supported in EPUB)
459
+ flattenNestedRules(root);
460
+
461
+ // Third pass: Collect CSS variables (from @property, :root, :host, .prose)
462
+ // Must happen before @property removal so initial-values are captured
463
+ const cssVariables = collectCSSVariables(root);
464
+
465
+ // Fourth pass: Remove @property declarations (unsupported in EPUB)
466
+ root.walkAtRules('property', atRule => atRule.remove());
467
+
468
+ // Fifth pass: Process rules to simplify selectors
469
+ root.walkRules(rule => {
470
+ // Remove callout :before rules.
471
+ // Normalise ::before → :before before comparing because PostCSS minification
472
+ // converts the canonical double-colon form to the legacy single-colon form in
473
+ // the compiled dist/style.css that this script receives as input.
474
+ const normSelector = rule.selector.replace(/::/g, ':');
475
+ if (CALLOUT_SELECTORS.some(sel => normSelector.includes(sel))) {
476
+ rule.remove();
477
+ return;
478
+ }
479
+
480
+ // Remove ::marker rules — EPUB readers have inconsistent ::marker support;
481
+ // stripping these lets list markers inherit the text color and remain visible.
482
+ if (rule.selector.includes('::marker') || rule.selector.includes(':marker')) {
483
+ rule.remove();
484
+ return;
485
+ }
486
+
487
+ // Remove #TOC a::after rules that use target-counter() for page numbers.
488
+ // CSS Paged Media functions (target-counter, counter) are not supported in
489
+ // EPUB/Kindle. Page numbers don't exist in reflowable ebooks.
490
+ if (rule.selector.includes('#TOC') && (rule.selector.includes('::after') || rule.selector.includes(':after'))) {
491
+ rule.remove();
492
+ return;
493
+ }
494
+
495
+ // Remove ::backdrop and ::file-selector-button — not applicable to EPUB
496
+ if (rule.selector.includes('::backdrop') || rule.selector.includes('::file-selector-button') || rule.selector.includes('::-webkit-datetime-edit')) {
497
+ // If it's a compound selector, strip just the EPUB-irrelevant parts
498
+ const parts = rule.selector.split(',').map(s => s.trim());
499
+ const filtered = parts.filter(s => !s.includes('::backdrop') && !s.includes('::file-selector-button') && !s.includes('::-webkit-datetime-edit'));
500
+ if (filtered.length === 0) {
501
+ rule.remove();
502
+ return;
503
+ }
504
+ rule.selector = filtered.join(', ');
505
+ }
506
+
507
+ // Remove rules targeting form elements not used in EPUB
508
+ // (button, select, optgroup, textarea, progress are never in book content)
509
+ const formOnlySelectors = /^(button|select|optgroup|textarea|progress)\b/;
510
+ if (formOnlySelectors.test(rule.selector.trim())) {
511
+ rule.remove();
512
+ return;
513
+ }
514
+
515
+ // Remove form/input pseudo-elements — not applicable to ebook content
516
+ const formPseudos = ['::-moz-placeholder', '::placeholder', '::-webkit-search-decoration',
517
+ '::-webkit-inner-spin-button', '::-webkit-calendar-picker-indicator',
518
+ ':-moz-ui-invalid', ':-moz-focusring', '::-moz-focusring',
519
+ '::-webkit-date-and-time-value'];
520
+ if (formPseudos.some(p => rule.selector.includes(p))) {
521
+ rule.remove();
522
+ return;
523
+ }
524
+
525
+ // Remove position: fixed/sticky and transition utilities — unsupported in EPUB
526
+ if (/^\.(fixed|sticky|transition)\b/.test(rule.selector.trim())) {
527
+ rule.remove();
528
+ return;
529
+ }
530
+
531
+ // Step 1: Convert attribute selectors
532
+ if (rule.selector.includes('[class~=') || rule.selector.match(/\[[^\]]+\s+[is]\]/)) {
533
+ rule.selector = convertAttributeSelectors(rule.selector);
534
+ }
535
+
536
+ // Step 2: Simplify :where() and :is() pseudo-classes
537
+ if (rule.selector.includes(':where(')) {
538
+ rule.selector = simplifyPseudoClass(rule.selector, ':where(');
539
+ }
540
+ if (rule.selector.includes(':is(')) {
541
+ rule.selector = simplifyPseudoClass(rule.selector, ':is(', true);
542
+ }
543
+
544
+ // Step 3: Simplify :not() with multiple selectors
545
+ if (rule.selector.includes(':not(') && rule.selector.match(/:not\([^)]*,/)) {
546
+ rule.selector = simplifyNotSelector(rule.selector);
547
+ }
548
+ });
549
+
550
+ // Fifth-B pass: Strip Tailwind Typography's .prose-scoped rules.
551
+ // Must run AFTER selector simplification (pass 5) because Tailwind v4
552
+ // uses :where([class~="not-prose"]) which gets simplified to
553
+ // :not(.not-prose) by the attribute/pseudo-class transforms above.
554
+ stripTypographyRules(root);
555
+
556
+ // Sixth pass: Process declarations - resolve CSS variables, convert colors,
557
+ // and convert logical properties to physical equivalents
558
+ // Note: When postcss-preset-env runs first (async pipeline), most of these
559
+ // are already handled. The custom code remains as a fallback for the sync
560
+ // version and catches anything the ecosystem tools miss.
561
+ root.walkDecls(decl => {
562
+ // Guard: postcss-preset-env may leave declarations with undefined values
563
+ if (!decl.value) {
564
+ decl.remove();
565
+ return;
566
+ }
567
+ // Drop callout-box break-inside:avoid so callouts don't leave blank
568
+ // gaps before page breaks on reflowable readers (Kindle, etc.).
569
+ if (stripCalloutBreakAvoid(decl)) return;
570
+ // Resolve CSS variables to static values for EPUB compatibility
571
+ if (decl.value.includes('var(')) {
572
+ decl.value = resolveCSSVariables(decl.value, cssVariables);
573
+ }
574
+ // Convert oklch colors
575
+ if (decl.value.includes('oklch(')) {
576
+ decl.value = decl.value.replace(/oklch\([^)]+\)/g, (match) => oklchToHex(match));
577
+ }
578
+ // Convert color-mix
579
+ if (decl.value.includes('color-mix(')) {
580
+ decl.value = decl.value.replace(/color-mix\([^)]+\)/g, (match) => colorMixToFallback(match));
581
+ }
582
+ // Convert CSS logical properties to physical equivalents (LTR)
583
+ // EPUB readers have inconsistent support for logical properties
584
+ if (LOGICAL_PROPERTY_MAP[decl.prop]) {
585
+ decl.prop = LOGICAL_PROPERTY_MAP[decl.prop];
586
+ }
587
+ // Convert flex/grid display values to block for EPUB compatibility
588
+ // Kindle and older e-readers have inconsistent flexbox/grid support
589
+ if (decl.prop === 'display') {
590
+ if (decl.value === 'flex' || decl.value === 'grid') {
591
+ decl.value = 'block';
592
+ } else if (decl.value === 'inline-flex' || decl.value === 'inline-grid') {
593
+ decl.value = 'inline-block';
594
+ }
595
+ }
596
+ // Remove flex/grid-only properties that are meaningless without flex/grid layout
597
+ const FLEX_GRID_ONLY_PROPS = [
598
+ 'flex-direction', 'flex-wrap', 'flex-flow', 'justify-content',
599
+ 'align-items', 'align-content', 'align-self', 'flex-grow',
600
+ 'flex-shrink', 'flex-basis', 'flex', 'order',
601
+ 'grid-template-columns', 'grid-template-rows', 'grid-template-areas',
602
+ 'grid-column', 'grid-row', 'grid-area', 'grid-gap', 'grid-auto-flow',
603
+ 'grid-auto-columns', 'grid-auto-rows', 'gap', 'row-gap', 'column-gap',
604
+ 'place-items', 'place-content', 'place-self',
605
+ ];
606
+ if (FLEX_GRID_ONLY_PROPS.includes(decl.prop)) {
607
+ decl.remove();
608
+ return;
609
+ }
610
+ // Convert rem to em for Kindle/e-reader compatibility.
611
+ // EPUB body font-size is always 1em (reader-controlled), making rem and em
612
+ // numerically equivalent. em is reliably supported across all e-readers
613
+ // while rem support varies on older Kindle firmware.
614
+ if (/\.?\d+rem\b/.test(decl.value)) {
615
+ decl.value = decl.value.replace(/(\d*\.?\d+)rem\b/g, '$1em');
616
+ }
617
+ // Strip float — Kindle has poor float support, especially on
618
+ // pseudo-elements like ::first-letter. EPUB layout should use
619
+ // block-level elements, not floats.
620
+ if (decl.prop === 'float') {
621
+ decl.remove();
622
+ return;
623
+ }
624
+ // Convert CSS open-quote/close-quote to actual Unicode characters.
625
+ // Kindle doesn't reliably support the CSS quotes property, so
626
+ // open-quote/close-quote may silently fail. Use explicit curly
627
+ // quote characters instead.
628
+ if (decl.prop === 'content') {
629
+ if (decl.value === 'open-quote') {
630
+ decl.value = '"\u201C"';
631
+ } else if (decl.value === 'close-quote') {
632
+ decl.value = '"\u201D"';
633
+ }
634
+ }
635
+ // Remove declarations with empty values (e.g., from Tailwind CSS v4 empty fallbacks
636
+ // like var(--tw-blur,) which resolve to empty strings, producing invalid CSS like
637
+ // "filter: ;")
638
+ if (decl.value.trim() === '') {
639
+ decl.remove();
640
+ }
641
+ });
642
+
643
+ // Seventh pass: Remove declarations with empty values left by unresolved variables
644
+ // (e.g., transform: ; or filter: ; from Tailwind utility classes)
645
+ root.walkDecls(decl => {
646
+ if (decl.value.trim() === '' || decl.value.trim() === ';') {
647
+ decl.remove();
648
+ }
649
+ });
650
+
651
+ // Remove rules that became empty after declaration cleanup
652
+ root.walkRules(rule => {
653
+ if (rule.nodes && rule.nodes.length === 0) {
654
+ rule.remove();
655
+ }
656
+ });
657
+
658
+ // Eighth pass: Clean up all CSS custom property declarations
659
+ // After resolving all var() references, custom property declarations
660
+ // are dead weight — no var() references remain to use them
661
+ root.walkDecls(decl => {
662
+ if (decl.prop.startsWith('--')) {
663
+ decl.remove();
664
+ }
665
+ });
666
+
667
+ // Remove rules that became empty after stripping variables
668
+ root.walkRules(rule => {
669
+ if (rule.nodes && rule.nodes.length === 0) {
670
+ rule.remove();
671
+ }
672
+ });
673
+
674
+ // Ninth pass: Pre-compute calc() expressions for EPUB compatibility
675
+ root.walkDecls(decl => {
676
+ if (decl.value.includes('calc(')) {
677
+ decl.value = decl.value.replace(/calc\(([^)]+)\)/g, (match, expr) => {
678
+ try {
679
+ // Only evaluate simple arithmetic (no variables, no complex expressions)
680
+ const sanitized = expr.trim();
681
+ if (/^[\d.+\-*/\s()]+$/.test(sanitized)) {
682
+ const result = Function('"use strict"; return (' + sanitized + ')')();
683
+ if (typeof result === 'number' && isFinite(result)) {
684
+ return String(Math.round(result * 10000) / 10000);
685
+ }
686
+ }
687
+ } catch { /* keep original if evaluation fails */ }
688
+ return match;
689
+ });
690
+ }
691
+ });
692
+ }
693
+ };
694
+ }
695
+ stripForEpubPlugin.postcss = true;
696
+
697
+ /**
698
+ * Flatten nested rules by hoisting them to root level
699
+ * @param {import('postcss').Root} root - PostCSS root
700
+ */
701
+ function flattenNestedRules(root) {
702
+ const rulesToHoist = [];
703
+
704
+ root.walkRules((rule) => {
705
+ // Find any rules nested inside this rule
706
+ rule.each((node) => {
707
+ if (node.type === 'rule') {
708
+ // This is a nested rule - we need to hoist it
709
+ // Combine parent and child selectors
710
+ const parentSelectors = rule.selector.split(',').map(s => s.trim());
711
+ const childSelectors = node.selector.split(',').map(s => s.trim());
712
+
713
+ const combinedSelectors = [];
714
+ for (const parent of parentSelectors) {
715
+ for (const child of childSelectors) {
716
+ // If child starts with &, replace it with parent
717
+ if (child.startsWith('&')) {
718
+ combinedSelectors.push(parent + child.substring(1));
719
+ } else {
720
+ // Otherwise, child is a descendant
721
+ combinedSelectors.push(`${parent} ${child}`);
722
+ }
723
+ }
724
+ }
725
+
726
+ // Create a new rule at root level with combined selector
727
+ const newRule = node.clone();
728
+ newRule.selector = combinedSelectors.join(', ');
729
+
730
+ rulesToHoist.push({ rule: newRule, after: rule });
731
+
732
+ // Remove the nested rule
733
+ node.remove();
734
+ }
735
+ });
736
+ });
737
+
738
+ // Insert hoisted rules after their parent rules, preserving original order
739
+ const lastInsertedByParent = new Map();
740
+ for (const { rule, after } of rulesToHoist) {
741
+ const referenceNode = lastInsertedByParent.get(after) || after;
742
+ referenceNode.after(rule);
743
+ lastInsertedByParent.set(after, rule);
744
+ }
745
+
746
+ // Repeat until no more nested rules (for deeply nested structures)
747
+ if (rulesToHoist.length > 0) {
748
+ flattenNestedRules(root);
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Simplify :where() or :is() pseudo-classes in a selector
754
+ * @param {string} selector - CSS selector
755
+ * @param {string} pseudoClass - Pseudo-class to simplify (e.g., ':where(' or ':is(')
756
+ * @param {boolean} firstOnly - If true, keep only first item in comma-separated list
757
+ * @returns {string} Simplified selector
758
+ */
759
+ function simplifyPseudoClass(selector, pseudoClass, firstOnly = false) {
760
+ let result = selector;
761
+
762
+ // Keep processing until no more instances of the pseudo-class remain
763
+ while (result.includes(pseudoClass)) {
764
+ const startIndex = result.indexOf(pseudoClass);
765
+ if (startIndex === -1) break;
766
+
767
+ const parenStart = startIndex + pseudoClass.length - 1; // Position of '('
768
+ const [innerContent, endPos] = extractBalancedParens(result, parenStart);
769
+
770
+ if (innerContent !== null) {
771
+ let replacement = innerContent;
772
+ if (firstOnly) {
773
+ // For :is(), keep only the first selector
774
+ const selectors = splitTopLevelCommas(innerContent);
775
+ replacement = selectors[0].trim();
776
+ }
777
+ result = result.slice(0, startIndex) + replacement + result.slice(endPos);
778
+ // Loop again to handle any nested pseudo-classes that were inside
779
+ } else {
780
+ // Can't extract properly, break to avoid infinite loop
781
+ break;
782
+ }
783
+ }
784
+
785
+ return result;
786
+ }
787
+
788
+ /**
789
+ * Extract content from balanced parentheses
790
+ * @param {string} content - String content
791
+ * @param {number} startPos - Position of opening parenthesis
792
+ * @returns {[string|null, number]} [content inside parens, position after closing paren]
793
+ */
794
+ function extractBalancedParens(content, startPos) {
795
+ if (content[startPos] !== '(') {
796
+ return [null, startPos];
797
+ }
798
+
799
+ let depth = 1;
800
+ let i = startPos + 1;
801
+
802
+ while (i < content.length && depth > 0) {
803
+ if (content[i] === '(') {
804
+ depth++;
805
+ } else if (content[i] === ')') {
806
+ depth--;
807
+ }
808
+ i++;
809
+ }
810
+
811
+ if (depth !== 0) {
812
+ return [null, startPos];
813
+ }
814
+
815
+ // Return content inside parens and position after closing paren
816
+ return [content.slice(startPos + 1, i - 1), i];
817
+ }
818
+
819
+ /**
820
+ * Split a string by commas, but only at the top level (not inside parentheses)
821
+ * @param {string} str - String to split
822
+ * @returns {string[]} Array of parts
823
+ */
824
+ function splitTopLevelCommas(str) {
825
+ const parts = [];
826
+ let current = '';
827
+ let depth = 0;
828
+
829
+ for (const char of str) {
830
+ if (char === '(') {
831
+ depth++;
832
+ current += char;
833
+ } else if (char === ')') {
834
+ depth--;
835
+ current += char;
836
+ } else if (char === ',' && depth === 0) {
837
+ parts.push(current);
838
+ current = '';
839
+ } else {
840
+ current += char;
841
+ }
842
+ }
843
+
844
+ if (current) {
845
+ parts.push(current);
846
+ }
847
+
848
+ return parts;
849
+ }
850
+
851
+ /**
852
+ * Strip all unsupported CSS features for EPUB using PostCSS
853
+ *
854
+ * Uses a two-phase pipeline:
855
+ * 1. postcss-preset-env: Standard CSS downgrading (oklch, color-mix, logical props)
856
+ * 2. stripForEpubPlugin: EPUB-specific stripping (@layer, @page, @media print,
857
+ * CSS variables, callouts, etc.)
858
+ *
859
+ * Note: CSS variable resolution is handled by stripForEpubPlugin (not postcss-preset-env)
860
+ * because postcss-preset-env's custom-properties feature creates duplicate rule blocks
861
+ * when expanding variables in Tailwind v4 output.
862
+ *
863
+ * @param {string} cssContent - Original CSS content
864
+ * @returns {Promise<string>} Processed CSS content
865
+ */
866
+ async function stripForEpub(cssContent) {
867
+ const postcssPresetEnv = (await import('postcss-preset-env')).default;
868
+
869
+ const result = await postcss([
870
+ // Phase 1: Standard CSS downgrading via ecosystem tools
871
+ // Handles oklch→hex, color-mix→static, logical→physical properties
872
+ postcssPresetEnv({
873
+ stage: 0,
874
+ features: {
875
+ // Variable resolution is handled by our custom plugin instead
876
+ 'custom-properties': false,
877
+ 'logical-properties-and-values': { dir: 'ltr' },
878
+ 'oklab-function': true,
879
+ 'color-mix': true,
880
+ },
881
+ }),
882
+ // Phase 2: EPUB-specific transformations
883
+ stripForEpubPlugin(),
884
+ ]).process(cssContent, { from: undefined });
885
+ return result.css;
886
+ }
887
+
888
+ /**
889
+ * Synchronous version for testing (processes string directly)
890
+ * @param {string} cssContent - Original CSS content
891
+ * @returns {string} Processed CSS content
892
+ */
893
+ function stripForEpubSync(cssContent) {
894
+ const root = postcss.parse(cssContent);
895
+
896
+ // First pass: Remove/unwrap at-rules (do this before collecting rules)
897
+ const nodesToRemove = [];
898
+ const nodesToUnwrap = [];
899
+
900
+ root.walkAtRules(atRule => {
901
+ if (atRule.name === 'supports' || atRule.name === 'page') {
902
+ nodesToRemove.push(atRule);
903
+ } else if (atRule.name === 'layer') {
904
+ if (atRule.nodes && atRule.nodes.length > 0) {
905
+ nodesToUnwrap.push(atRule);
906
+ } else {
907
+ nodesToRemove.push(atRule);
908
+ }
909
+ } else if (atRule.name === 'media' && atRule.params.trim() === 'print') {
910
+ // Remove @media print blocks — EPUB readers ignore print styles
911
+ nodesToRemove.push(atRule);
912
+ } else if (atRule.name === 'media' && atRule.parent && atRule.parent.type === 'rule') {
913
+ nodesToRemove.push(atRule);
914
+ } else if (atRule.name === 'media' && atRule.params && /^screen/.test(atRule.params.trim())) {
915
+ // Strip @media screen (and @media screen and ...) — these are web-browser-specific.
916
+ // EPUB readers use global styles; viewport units and flex within screen queries
917
+ // are unreliable in ebook readers (Kindle, Apple Books, Kobo, etc.).
918
+ nodesToRemove.push(atRule);
919
+ }
920
+ });
921
+
922
+ nodesToRemove.forEach(node => node.remove());
923
+ nodesToUnwrap.forEach(node => node.replaceWith(node.nodes));
924
+
925
+ // Second pass: Flatten nested rules
926
+ flattenNestedRules(root);
927
+
928
+ // Third pass: Collect CSS variables (from @property, :root, :host, .prose)
929
+ // Must happen before @property removal so initial-values are captured
930
+ const cssVariables = collectCSSVariables(root);
931
+
932
+ // Fourth pass: Remove @property declarations (unsupported in EPUB)
933
+ root.walkAtRules('property', atRule => atRule.remove());
934
+
935
+ // Fifth pass: Process rules to simplify selectors
936
+ root.walkRules(rule => {
937
+ // Remove callout :before rules.
938
+ // Normalise ::before → :before before comparing because PostCSS minification
939
+ // converts the canonical double-colon form to the legacy single-colon form in
940
+ // the compiled dist/style.css that this script receives as input.
941
+ const normSelector = rule.selector.replace(/::/g, ':');
942
+ if (CALLOUT_SELECTORS.some(sel => normSelector.includes(sel))) {
943
+ rule.remove();
944
+ return;
945
+ }
946
+
947
+ // Remove ::marker rules — EPUB readers have inconsistent ::marker support;
948
+ // stripping these lets list markers inherit the text color and remain visible.
949
+ if (rule.selector.includes('::marker') || rule.selector.includes(':marker')) {
950
+ rule.remove();
951
+ return;
952
+ }
953
+
954
+ // Remove ::backdrop and ::file-selector-button — not applicable to EPUB
955
+ if (rule.selector.includes('::backdrop') || rule.selector.includes('::file-selector-button') || rule.selector.includes('::-webkit-datetime-edit')) {
956
+ const parts = rule.selector.split(',').map(s => s.trim());
957
+ const filtered = parts.filter(s => !s.includes('::backdrop') && !s.includes('::file-selector-button') && !s.includes('::-webkit-datetime-edit'));
958
+ if (filtered.length === 0) {
959
+ rule.remove();
960
+ return;
961
+ }
962
+ rule.selector = filtered.join(', ');
963
+ }
964
+
965
+ // Remove rules targeting form elements not used in EPUB
966
+ const formOnlySelectors = /^(button|select|optgroup|textarea|progress)\b/;
967
+ if (formOnlySelectors.test(rule.selector.trim())) {
968
+ rule.remove();
969
+ return;
970
+ }
971
+
972
+ // Step 1: Convert attribute selectors
973
+ if (rule.selector.includes('[class~=') || rule.selector.match(/\[[^\]]+\s+[is]\]/)) {
974
+ rule.selector = convertAttributeSelectors(rule.selector);
975
+ }
976
+
977
+ // Step 2: Simplify :where() and :is() pseudo-classes
978
+ if (rule.selector.includes(':where(')) {
979
+ rule.selector = simplifyPseudoClass(rule.selector, ':where(');
980
+ }
981
+ if (rule.selector.includes(':is(')) {
982
+ rule.selector = simplifyPseudoClass(rule.selector, ':is(', true);
983
+ }
984
+
985
+ // Step 3: Simplify :not() with multiple selectors
986
+ if (rule.selector.includes(':not(') && rule.selector.match(/:not\([^)]*,/)) {
987
+ rule.selector = simplifyNotSelector(rule.selector);
988
+ }
989
+ });
990
+
991
+ // Fifth-B pass: Strip Tailwind Typography's .prose-scoped rules.
992
+ // Must run AFTER selector simplification (pass 5) — see async version comment.
993
+ stripTypographyRules(root);
994
+
995
+ // Sixth pass: Process declarations - resolve CSS variables, convert colors,
996
+ // and convert logical properties to physical equivalents
997
+ root.walkDecls(decl => {
998
+ // Drop callout-box break-inside:avoid (see async version / helper comment).
999
+ if (stripCalloutBreakAvoid(decl)) return;
1000
+ // Resolve CSS variables to static values for EPUB compatibility
1001
+ if (decl.value.includes('var(')) {
1002
+ decl.value = resolveCSSVariables(decl.value, cssVariables);
1003
+ }
1004
+ // Convert oklch colors
1005
+ if (decl.value.includes('oklch(')) {
1006
+ decl.value = decl.value.replace(/oklch\([^)]+\)/g, (match) => oklchToHex(match));
1007
+ }
1008
+ // Convert color-mix
1009
+ if (decl.value.includes('color-mix(')) {
1010
+ decl.value = decl.value.replace(/color-mix\([^)]+\)/g, (match) => colorMixToFallback(match));
1011
+ }
1012
+ // Convert CSS logical properties to physical equivalents (LTR)
1013
+ // EPUB readers have inconsistent support for logical properties
1014
+ if (LOGICAL_PROPERTY_MAP[decl.prop]) {
1015
+ decl.prop = LOGICAL_PROPERTY_MAP[decl.prop];
1016
+ }
1017
+ // Convert flex/grid display values to block for EPUB compatibility
1018
+ if (decl.prop === 'display') {
1019
+ if (decl.value === 'flex' || decl.value === 'grid') {
1020
+ decl.value = 'block';
1021
+ } else if (decl.value === 'inline-flex' || decl.value === 'inline-grid') {
1022
+ decl.value = 'inline-block';
1023
+ }
1024
+ }
1025
+ // Convert rem to em for Kindle/e-reader compatibility.
1026
+ // EPUB body font-size is always 1em (reader-controlled), making rem and em
1027
+ // numerically equivalent. em is reliably supported across all e-readers
1028
+ // while rem support varies on older Kindle firmware.
1029
+ if (/\.?\d+rem\b/.test(decl.value)) {
1030
+ decl.value = decl.value.replace(/(\d*\.?\d+)rem\b/g, '$1em');
1031
+ }
1032
+ // Strip float — Kindle has poor float support, especially on
1033
+ // pseudo-elements like ::first-letter.
1034
+ if (decl.prop === 'float') {
1035
+ decl.remove();
1036
+ return;
1037
+ }
1038
+ // Convert CSS open-quote/close-quote to actual Unicode characters.
1039
+ if (decl.prop === 'content') {
1040
+ if (decl.value === 'open-quote') {
1041
+ decl.value = '"\u201C"';
1042
+ } else if (decl.value === 'close-quote') {
1043
+ decl.value = '"\u201D"';
1044
+ }
1045
+ }
1046
+ // Remove flex/grid-only properties that are meaningless without flex/grid layout
1047
+ const FLEX_GRID_ONLY_PROPS = [
1048
+ 'flex-direction', 'flex-wrap', 'flex-flow', 'justify-content',
1049
+ 'align-items', 'align-content', 'align-self', 'flex-grow',
1050
+ 'flex-shrink', 'flex-basis', 'flex', 'order',
1051
+ 'grid-template-columns', 'grid-template-rows', 'grid-template-areas',
1052
+ 'grid-column', 'grid-row', 'grid-area', 'grid-gap', 'grid-auto-flow',
1053
+ 'grid-auto-columns', 'grid-auto-rows', 'gap', 'row-gap', 'column-gap',
1054
+ 'place-items', 'place-content', 'place-self',
1055
+ ];
1056
+ if (FLEX_GRID_ONLY_PROPS.includes(decl.prop)) {
1057
+ decl.remove();
1058
+ return;
1059
+ }
1060
+ });
1061
+
1062
+ // Seventh pass: Clean up all CSS custom property declarations
1063
+ root.walkDecls(decl => {
1064
+ if (decl.prop.startsWith('--')) {
1065
+ decl.remove();
1066
+ }
1067
+ });
1068
+
1069
+ // Remove rules that became empty after stripping variables
1070
+ root.walkRules(rule => {
1071
+ if (rule.nodes && rule.nodes.length === 0) {
1072
+ rule.remove();
1073
+ }
1074
+ });
1075
+
1076
+ // Eighth pass: Pre-compute calc() expressions
1077
+ root.walkDecls(decl => {
1078
+ if (decl.value.includes('calc(')) {
1079
+ decl.value = decl.value.replace(/calc\(([^)]+)\)/g, (match, expr) => {
1080
+ try {
1081
+ const sanitized = expr.trim();
1082
+ if (/^[\d.+\-*/\s()]+$/.test(sanitized)) {
1083
+ const result = Function('"use strict"; return (' + sanitized + ')')();
1084
+ if (typeof result === 'number' && isFinite(result)) {
1085
+ return String(Math.round(result * 10000) / 10000);
1086
+ }
1087
+ }
1088
+ } catch { /* keep original if evaluation fails */ }
1089
+ return match;
1090
+ });
1091
+ }
1092
+ });
1093
+
1094
+ return root.toString();
1095
+ }
1096
+
1097
+ /**
1098
+ * Process CSS file and write output
1099
+ * @param {string} inputFile - Path to input CSS file
1100
+ * @param {string} outputFile - Path to output CSS file
1101
+ */
1102
+ async function processFile(inputFile, outputFile) {
1103
+ const cssContent = fs.readFileSync(inputFile, 'utf8');
1104
+ // Use async version to ensure both code paths stay in sync
1105
+ const processedContent = await stripForEpub(cssContent);
1106
+
1107
+ fs.writeFileSync(outputFile, processedContent);
1108
+
1109
+ console.log(`Stripped unsupported CSS features from ${inputFile} -> ${outputFile}`);
1110
+ console.log(' - @layer directives (unwrapped, content preserved)');
1111
+ console.log(' - @supports feature queries (removed)');
1112
+ console.log(' - @page rules (removed)');
1113
+ console.log(' - @media print blocks (removed)');
1114
+ console.log(' - @property declarations (initial-values captured, then removed)');
1115
+ console.log(' - Nested @media rules (CSS Nesting - removed)');
1116
+ console.log(' - CSS Nesting (flattened to EPUB-compatible syntax)');
1117
+ console.log(' - :where() and :is() pseudo-classes (simplified)');
1118
+ console.log(' - :not() with multiple selectors (simplified to first selector)');
1119
+ console.log(' - Callout ::before pseudo-elements (removed)');
1120
+ console.log(' - ::marker pseudo-elements (removed)');
1121
+ console.log(' - ::backdrop and ::file-selector-button pseudo-elements (removed)');
1122
+ console.log(' - Form element selectors (button, select, textarea, etc. removed)');
1123
+ console.log(' - Attribute selectors with ~= (converted to classes)');
1124
+ console.log(' - Attribute selector case-sensitivity flags (removed)');
1125
+ console.log(' - CSS custom properties/variables (resolved to static values)');
1126
+ console.log(' - CSS custom property declarations (removed from :root)');
1127
+ console.log(' - oklch() color functions (converted to hex)');
1128
+ console.log(' - color-mix() functions (converted to fallback)');
1129
+ console.log(' - CSS logical properties (converted to physical equivalents)');
1130
+ console.log(' - calc() expressions (pre-computed where possible)');
1131
+ console.log(' - rem units (converted to em for Kindle compatibility)');
1132
+ console.log(' - Tailwind Typography .prose rules (stripped to eliminate cascade conflicts)');
1133
+ console.log(' - float properties (stripped for Kindle compatibility)');
1134
+ console.log(' - open-quote/close-quote (converted to Unicode curly quotes)');
1135
+ }
1136
+
1137
+ // Export for testing
1138
+ export {
1139
+ extractBalancedParens,
1140
+ simplifyPseudoClass,
1141
+ splitTopLevelCommas,
1142
+ stripForEpub,
1143
+ stripForEpubSync,
1144
+ stripForEpubPlugin,
1145
+ stripTypographyRules,
1146
+ processFile,
1147
+ CALLOUT_SELECTORS,
1148
+ LOGICAL_PROPERTY_MAP,
1149
+ convertAttributeSelectors,
1150
+ simplifyNotSelector,
1151
+ oklchToHex,
1152
+ colorMixToFallback,
1153
+ flattenNestedRules,
1154
+ collectCSSVariables,
1155
+ resolveCSSVariables,
1156
+ resolveVarReference
1157
+ };
1158
+
1159
+ // Run if executed directly
1160
+ if (isMain(import.meta.url)) {
1161
+ const inputFile = process.argv[2] || 'dist/style.css';
1162
+ const outputFile = process.argv[3] || 'dist/style-epub.css';
1163
+
1164
+ processFile(inputFile, outputFile).catch((err) => {
1165
+ console.error(err);
1166
+ process.exit(1);
1167
+ });
1168
+ }