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,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
|
+
}
|