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
package/lib/is-main.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// True when the module at `metaUrl` is the script node was asked to run, as
|
|
2
|
+
// opposed to being imported (by another command or a test).
|
|
3
|
+
//
|
|
4
|
+
// Commands used to compare process.argv[1] with fileURLToPath(import.meta.url)
|
|
5
|
+
// directly. Node reports import.meta.url as the real path, but argv[1] as the
|
|
6
|
+
// path it was given, so through a symlinked node_modules (npm link, pnpm, a
|
|
7
|
+
// worktree sharing another checkout's install) the two differ and the command
|
|
8
|
+
// silently did nothing. Compare real paths instead.
|
|
9
|
+
|
|
10
|
+
import { realpathSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
export function isMain(metaUrl) {
|
|
14
|
+
const script = process.argv[1];
|
|
15
|
+
if (!script) return false;
|
|
16
|
+
try {
|
|
17
|
+
return realpathSync(script) === realpathSync(fileURLToPath(metaUrl));
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/lib/lint.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Resolved lint configs for a book: the kit's base configs, layered with the
|
|
2
|
+
// presets' and the book's settings, the same way book.config.yml is.
|
|
3
|
+
//
|
|
4
|
+
// textlint configs/textlintrc.json, deep-merged with lint.textlint
|
|
5
|
+
// kit rules rules/*.js and each preset's rules/*.js, switched on and
|
|
6
|
+
// configured by lint.rules (name -> true | false | options)
|
|
7
|
+
// markdownlint configs/markdownlint-cli2.jsonc's `config`, deep-merged
|
|
8
|
+
// with lint.markdownlint
|
|
9
|
+
//
|
|
10
|
+
// textlint gives rules loaded with --rulesdir no config at all (they can't
|
|
11
|
+
// be switched off or given options), and only rules named by package or path
|
|
12
|
+
// in a textlintrc get options. So the build writes one rules directory per
|
|
13
|
+
// book holding a small wrapper for each enabled rule, named like the rule and
|
|
14
|
+
// with its options baked in, and points --rulesdir at that.
|
|
15
|
+
//
|
|
16
|
+
// A book with its own .textlintrc or .markdownlint-cli2.jsonc uses that file
|
|
17
|
+
// as is (and, for textlint, every kit rule), as before presets existed.
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { basename, join } from 'node:path';
|
|
21
|
+
import { deepMerge, kitPath, pathFor } from './config.js';
|
|
22
|
+
|
|
23
|
+
const BOOK_TEXTLINTRC = '.textlintrc';
|
|
24
|
+
const BOOK_MARKDOWNLINT = '.markdownlint-cli2.jsonc';
|
|
25
|
+
|
|
26
|
+
const ruleFiles = (dir) =>
|
|
27
|
+
existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.js')).sort().map((f) => join(dir, f)) : [];
|
|
28
|
+
|
|
29
|
+
const ruleName = (file) => basename(file, '.js');
|
|
30
|
+
|
|
31
|
+
// Our own JSONC files only use whole-line // comments.
|
|
32
|
+
function readJsonc(file) {
|
|
33
|
+
return JSON.parse(readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The textlint rules a book enables, in name order: [{ name, file, options }].
|
|
38
|
+
* Kit rules are on only when lint.rules turns them on; a preset's rules are
|
|
39
|
+
* on unless lint.rules turns them off.
|
|
40
|
+
* @param {object} config - Resolved config
|
|
41
|
+
*/
|
|
42
|
+
export function enabledRules(config) {
|
|
43
|
+
const settings = config.lint?.rules ?? {};
|
|
44
|
+
const candidates = [
|
|
45
|
+
...ruleFiles(kitPath('rules')).map((file) => ({ file, on: Boolean(settings[ruleName(file)]) })),
|
|
46
|
+
...(config.presets ?? []).flatMap(({ dir }) =>
|
|
47
|
+
ruleFiles(join(dir, 'rules')).map((file) => ({ file, on: settings[ruleName(file)] !== false })),
|
|
48
|
+
),
|
|
49
|
+
];
|
|
50
|
+
const byName = new Map();
|
|
51
|
+
for (const { file, on } of candidates) byName.set(ruleName(file), { file, on }); // later layers win
|
|
52
|
+
return [...byName.entries()]
|
|
53
|
+
.filter(([, { on }]) => on)
|
|
54
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
55
|
+
.map(([name, { file }]) => {
|
|
56
|
+
const setting = settings[name];
|
|
57
|
+
const options = setting && typeof setting === 'object' ? setting : {};
|
|
58
|
+
return { name, file, options };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether command-line arguments for textlint or markdownlint name what to
|
|
64
|
+
* lint. Options and their values pass through untouched, so a value like
|
|
65
|
+
* `--format json` or a formatter's .js path isn't taken for a file: an
|
|
66
|
+
* argument names files only when it ends in .md, is a glob, or is a directory.
|
|
67
|
+
* @param {string[]} args
|
|
68
|
+
* @param {string} cwd - Where relative paths resolve (the book root)
|
|
69
|
+
*/
|
|
70
|
+
export function namesFiles(args, cwd) {
|
|
71
|
+
return args.some((arg) => {
|
|
72
|
+
if (arg.startsWith('-')) return false;
|
|
73
|
+
if (arg.endsWith('.md') || arg.includes('*')) return true;
|
|
74
|
+
try {
|
|
75
|
+
return statSync(join(cwd, arg)).isDirectory();
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The textlintrc a book gets: the kit base merged with lint.textlint. */
|
|
83
|
+
export function textlintConfig(config) {
|
|
84
|
+
return deepMerge(readJsonc(kitPath('configs', 'textlintrc.json')), config.lint?.textlint ?? {});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The markdownlint-cli2 config a book gets: the kit base merged with lint.markdownlint. */
|
|
88
|
+
// Drafts (structure.draft_suffix) aren't linted.
|
|
89
|
+
export function markdownlintConfig(config) {
|
|
90
|
+
const base = readJsonc(kitPath('configs', 'markdownlint-cli2.jsonc'));
|
|
91
|
+
const drafts = `**/*${config.structure?.draft_suffix ?? '-scratch'}.md`;
|
|
92
|
+
return {
|
|
93
|
+
...base,
|
|
94
|
+
config: deepMerge(base.config, config.lint?.markdownlint ?? {}),
|
|
95
|
+
ignores: [drafts, ...(base.ignores ?? [])],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function wrapperSource({ name, file, options }) {
|
|
100
|
+
return `// Generated by book-kit for the book's textlint run (lib/lint.js): the
|
|
101
|
+
// ${name} rule with its lint.rules options. Edit book.config.yml, not this file.
|
|
102
|
+
import rule from ${JSON.stringify(file)};
|
|
103
|
+
|
|
104
|
+
const options = ${JSON.stringify(options)};
|
|
105
|
+
const withOptions = (reporter) => reporter && ((context, own = {}) => reporter(context, { ...options, ...own }));
|
|
106
|
+
|
|
107
|
+
export default typeof rule === 'function'
|
|
108
|
+
? withOptions(rule)
|
|
109
|
+
: { ...rule, linter: withOptions(rule.linter), fixer: withOptions(rule.fixer) };
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write the book's textlint config and rules directory under
|
|
115
|
+
* dist/.book-kit/textlint/, or point at the book's own .textlintrc.
|
|
116
|
+
* @param {object} config - Resolved config
|
|
117
|
+
* @returns {{ config: string, rulesdir: string }} paths for --config and --rulesdir
|
|
118
|
+
*/
|
|
119
|
+
export function prepareTextlint(config) {
|
|
120
|
+
const own = join(config.root, BOOK_TEXTLINTRC);
|
|
121
|
+
if (existsSync(own)) return { config: own, rulesdir: kitPath('rules') };
|
|
122
|
+
|
|
123
|
+
const dir = join(pathFor('dist', config), '.book-kit', 'textlint');
|
|
124
|
+
const rulesdir = join(dir, 'rules');
|
|
125
|
+
rmSync(rulesdir, { recursive: true, force: true }); // drop rules the book has since turned off
|
|
126
|
+
mkdirSync(rulesdir, { recursive: true });
|
|
127
|
+
// The wrappers are ES modules, whatever the book's own package.json says.
|
|
128
|
+
writeFileSync(join(dir, 'package.json'), '{ "type": "module" }\n');
|
|
129
|
+
for (const rule of enabledRules(config)) writeFileSync(join(rulesdir, `${rule.name}.js`), wrapperSource(rule));
|
|
130
|
+
const out = join(dir, 'textlintrc.json');
|
|
131
|
+
writeFileSync(out, `${JSON.stringify(textlintConfig(config), null, 2)}\n`);
|
|
132
|
+
return { config: out, rulesdir };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Write the book's markdownlint-cli2 config, or point at the book's own.
|
|
137
|
+
* @param {object} config - Resolved config
|
|
138
|
+
* @returns {string} path for markdownlint-cli2 --config
|
|
139
|
+
*/
|
|
140
|
+
export function prepareMarkdownlint(config) {
|
|
141
|
+
const own = join(config.root, BOOK_MARKDOWNLINT);
|
|
142
|
+
if (existsSync(own)) return own;
|
|
143
|
+
const dir = join(pathFor('dist', config), '.book-kit');
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
const out = join(dir, 'markdownlint-cli2.jsonc');
|
|
146
|
+
writeFileSync(out, `${JSON.stringify(markdownlintConfig(config), null, 2)}\n`);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
package/lib/pandoc.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Generates dist/.book-kit/pandoc-<format>.yml: the per-book half of the pandoc
|
|
2
|
+
// defaults (identity metadata, resource paths, cover), derived from
|
|
3
|
+
// book.config.yml. Recipes pass it after the kit's identity-free defaults in
|
|
4
|
+
// defaults/, so pandoc's metadata merge lets it win.
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import * as yaml from 'js-yaml';
|
|
9
|
+
import { kitPath, loadConfig, pathFor } from './config.js';
|
|
10
|
+
import { revision } from './revision.js';
|
|
11
|
+
|
|
12
|
+
export const FORMATS = ['html', 'pdf', 'epub', 'kindle', 'docx'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A filter or template a book names in build.filters / build.templates. A
|
|
16
|
+
* path (containing "/" or starting with ".") is book-relative; a bare name is
|
|
17
|
+
* looked up in the presets' <kind>/ directories, last preset first, then the
|
|
18
|
+
* kit's.
|
|
19
|
+
* @param {string} spec - e.g. "revision", "template.html", "./filters/mine.lua"
|
|
20
|
+
* @param {'filters'|'templates'} kind
|
|
21
|
+
* @param {string} ext - appended to a bare name that lacks it ('.lua' for filters)
|
|
22
|
+
*/
|
|
23
|
+
export function resolveAsset(spec, kind, ext, config) {
|
|
24
|
+
if (spec.includes('/') || spec.startsWith('.')) return join(config.root, spec);
|
|
25
|
+
const file = spec.endsWith(ext) ? spec : `${spec}${ext}`;
|
|
26
|
+
for (const { dir } of [...(config.presets ?? [])].reverse()) {
|
|
27
|
+
const candidate = join(dir, kind, file);
|
|
28
|
+
if (existsSync(candidate)) return candidate;
|
|
29
|
+
}
|
|
30
|
+
const kit = kitPath(kind, file);
|
|
31
|
+
if (!existsSync(kit)) {
|
|
32
|
+
throw new Error(`build.${kind}: no ${kind.slice(0, -1)} named "${spec}" in the kit or a preset (use ./path for one in the book)`);
|
|
33
|
+
}
|
|
34
|
+
return kit;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The Lua filter chain for a format: build.filters.<format>, in order, with
|
|
38
|
+
// the literal entry "extra_filters" standing for build.extra_filters.<format>
|
|
39
|
+
// (book-relative paths).
|
|
40
|
+
function filtersFor(format, config) {
|
|
41
|
+
const extra = (config.build.extra_filters?.[format] ?? []).map((f) => join(config.root, f));
|
|
42
|
+
return (config.build.filters?.[format] ?? []).flatMap((spec) =>
|
|
43
|
+
spec === 'extra_filters' ? extra : [resolveAsset(spec, 'filters', '.lua', config)],
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildDate() {
|
|
48
|
+
const epoch = Number(process.env.SOURCE_DATE_EPOCH);
|
|
49
|
+
return (Number.isFinite(epoch) && epoch > 0 ? new Date(epoch * 1000) : new Date()).toISOString().slice(0, 10);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
53
|
+
|
|
54
|
+
// Dublin Core supplement for the OPF, for books without a hand-written
|
|
55
|
+
// epub.xml. metadata.uuid must stay constant across every revision of the book.
|
|
56
|
+
export function epubMetadataXml(config = loadConfig()) {
|
|
57
|
+
const m = config.metadata;
|
|
58
|
+
if (!m.uuid) {
|
|
59
|
+
throw new Error('metadata.uuid is required to generate EPUB metadata (any UUID; never change it). Try: node -e "console.log(crypto.randomUUID())"');
|
|
60
|
+
}
|
|
61
|
+
const lines = [`<dc:identifier id="epub-id">urn:uuid:${xml(m.uuid)}</dc:identifier>`];
|
|
62
|
+
if (m.isbn_ebook) lines.push(`<dc:identifier id="isbn-ebook">urn:isbn:${xml(m.isbn_ebook)}</dc:identifier>`);
|
|
63
|
+
// Always emit a date: without one, pandoc derives dc:date from the `date`
|
|
64
|
+
// metadata (the build stamp, not a date) and writes an empty element, which
|
|
65
|
+
// fails EPUBCheck. Unpublished books get the build date (SOURCE_DATE_EPOCH
|
|
66
|
+
// when set, for reproducible builds).
|
|
67
|
+
lines.push(`<dc:date>${xml(m.pub_date || buildDate())}</dc:date>`);
|
|
68
|
+
if (m.publisher) lines.push(`<dc:publisher>${xml(m.publisher)}</dc:publisher>`);
|
|
69
|
+
for (const subject of m.subjects ?? []) lines.push(`<dc:subject>${xml(subject)}</dc:subject>`);
|
|
70
|
+
return `${lines.join('\n')}\n`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isEmpty = (v) => v == null || v === '' || (Array.isArray(v) && v.length === 0);
|
|
74
|
+
|
|
75
|
+
function compact(obj) {
|
|
76
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => !isEmpty(v)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function pandocDefaults(format, config = loadConfig()) {
|
|
80
|
+
if (!FORMATS.includes(format)) throw new Error(`Unknown format "${format}" (expected ${FORMATS.join(', ')})`);
|
|
81
|
+
const m = config.metadata;
|
|
82
|
+
const ebook = format === 'epub' || format === 'kindle';
|
|
83
|
+
|
|
84
|
+
const metadata = compact({
|
|
85
|
+
title: m.title,
|
|
86
|
+
subtitle: m.subtitle,
|
|
87
|
+
description: format === 'docx' ? undefined : m.description,
|
|
88
|
+
author: [].concat(m.author ?? []),
|
|
89
|
+
lang: m.lang,
|
|
90
|
+
date: revision(config),
|
|
91
|
+
rights: format === 'docx' ? undefined : m.rights,
|
|
92
|
+
// The print ISBN identifies the PDF/HTML; the ebook ISBN goes in the OPF
|
|
93
|
+
// via paths.epub_metadata (dc:identifier), not here.
|
|
94
|
+
isbn: format === 'html' || format === 'pdf' ? m.isbn_paperback : undefined,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Point at the compiled stylesheet in dist/ explicitly, so pandoc never picks
|
|
98
|
+
// up the uncompiled Tailwind source through the resource path.
|
|
99
|
+
const STYLESHEETS = { html: 'style.css', pdf: 'style.css', epub: 'style-epub.css', kindle: 'style-kindle.css' };
|
|
100
|
+
if (STYLESHEETS[format]) metadata.css = join(config.paths.dist, STYLESHEETS[format]);
|
|
101
|
+
|
|
102
|
+
if (ebook) {
|
|
103
|
+
const a = config.accessibility;
|
|
104
|
+
Object.assign(metadata, compact({
|
|
105
|
+
'cover-label': config.strings?.cover_label,
|
|
106
|
+
accessModes: a.access_modes,
|
|
107
|
+
accessModeSufficient: a.access_mode_sufficient,
|
|
108
|
+
accessibilityFeatures: a.features,
|
|
109
|
+
accessibilityHazards: a.hazards,
|
|
110
|
+
accessibilitySummary: a.summary,
|
|
111
|
+
conformsTo: a.conforms_to,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
Object.assign(metadata, config.pandoc_metadata);
|
|
115
|
+
|
|
116
|
+
const out = {
|
|
117
|
+
metadata,
|
|
118
|
+
'resource-path': ['.', config.paths.fonts, config.paths.src],
|
|
119
|
+
filters: filtersFor(format, config),
|
|
120
|
+
};
|
|
121
|
+
const template = config.build.templates?.[format];
|
|
122
|
+
if (template) out.template = resolveAsset(template, 'templates', '', config);
|
|
123
|
+
if (ebook) out['epub-metadata'] = epubMetadataPath(config);
|
|
124
|
+
if (format !== 'docx' && existsSync(pathFor('cover', config))) {
|
|
125
|
+
out['epub-cover-image'] = config.paths.cover;
|
|
126
|
+
}
|
|
127
|
+
if (format === 'docx' && existsSync(join(config.root, 'reference.docx'))) {
|
|
128
|
+
out['reference-doc'] = 'reference.docx';
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The book's epub.xml if it has one, else one generated from config.
|
|
134
|
+
function epubMetadataPath(config) {
|
|
135
|
+
const own = pathFor('epub_metadata', config);
|
|
136
|
+
if (existsSync(own)) return own;
|
|
137
|
+
const out = join(pathFor('dist', config), '.book-kit', 'epub.xml');
|
|
138
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
139
|
+
writeFileSync(out, epubMetadataXml(config));
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function writePandocDefaults(format, config = loadConfig()) {
|
|
144
|
+
const out = join(pathFor('dist', config), '.book-kit', `pandoc-${format}.yml`);
|
|
145
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
146
|
+
writeFileSync(out, yaml.dump(pandocDefaults(format, config), { lineWidth: -1 }));
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared paragraph extraction for prose-length analysis.
|
|
3
|
+
*
|
|
4
|
+
* Splits a chapter's Markdown into prose paragraphs (body text and callout
|
|
5
|
+
* text), skipping code blocks, headings, list items, blockquotes, tables,
|
|
6
|
+
* and footnote definitions. Used by the paragraph-length validator
|
|
7
|
+
* (validators/paragraph-length.js); keep any other paragraph analysis on this
|
|
8
|
+
* one implementation so thresholds and reports always agree.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Maximum words per paragraph before it's flagged as a skim-ability problem.
|
|
13
|
+
* Fixed rather than recomputed from the book (which would make it a moving
|
|
14
|
+
* target), and high enough to flag only the longest few percent of paragraphs
|
|
15
|
+
* in typical nonfiction prose. Tune it with validators.options.paragraph-length.
|
|
16
|
+
*/
|
|
17
|
+
import { isNonChapter } from './structure.js';
|
|
18
|
+
|
|
19
|
+
const MAX_PARAGRAPH_WORDS = 120;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Strip inline Markdown/Pandoc markup from a paragraph, preserving visible
|
|
23
|
+
* text. Heuristic, not a parser — sufficient for word/character counting.
|
|
24
|
+
* @param {string} text - Single paragraph of Markdown
|
|
25
|
+
* @returns {string} Plaintext-ish content
|
|
26
|
+
*/
|
|
27
|
+
function stripInlineMarkup(text) {
|
|
28
|
+
let stripped = text;
|
|
29
|
+
|
|
30
|
+
// Inline code: collapse to a single-word placeholder so `git commit -m "…"`
|
|
31
|
+
// counts as one token, not five
|
|
32
|
+
stripped = stripped.replace(/`[^`]*`/g, 'code');
|
|
33
|
+
|
|
34
|
+
// Images: keep alt text, drop URLs
|
|
35
|
+
stripped = stripped.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1 ');
|
|
36
|
+
|
|
37
|
+
// Links: keep link text, drop URLs
|
|
38
|
+
stripped = stripped.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1 ');
|
|
39
|
+
|
|
40
|
+
// Pandoc inline spans, e.g. [API]{.smallcaps}: keep the text
|
|
41
|
+
stripped = stripped.replace(/\[([^\]]*)\]\{[^}]*\}/g, '$1');
|
|
42
|
+
|
|
43
|
+
// Footnote references: not visible prose
|
|
44
|
+
stripped = stripped.replace(/\[\^[^\]]+\]/g, '');
|
|
45
|
+
|
|
46
|
+
// Emphasis and strong emphasis markers (*, _, **, __)
|
|
47
|
+
stripped = stripped.replace(/(\*\*|__)(.*?)\1/g, '$2');
|
|
48
|
+
stripped = stripped.replace(/(\*|_)(.*?)\1/g, '$2');
|
|
49
|
+
|
|
50
|
+
// Collapse whitespace
|
|
51
|
+
stripped = stripped.replace(/\s+/g, ' ').trim();
|
|
52
|
+
|
|
53
|
+
return stripped;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract prose paragraphs from chapter Markdown.
|
|
58
|
+
*
|
|
59
|
+
* A paragraph is a run of consecutive non-blank prose lines. Boundaries:
|
|
60
|
+
* blank lines, headings, list items, blockquotes, tables, footnote
|
|
61
|
+
* definitions, HTML comments, and fenced div markers (:::). Fenced code
|
|
62
|
+
* blocks are skipped entirely. Paragraphs inside fenced divs (callouts:
|
|
63
|
+
* .tldr, .pro-tip, .for-managers, etc.) are included and tagged
|
|
64
|
+
* `inCallout: true` — a wall of text in a box is still a wall of text.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} content - Raw chapter Markdown
|
|
67
|
+
* @returns {Array<{line: number, text: string, plain: string, words: number,
|
|
68
|
+
* chars: number, sentences: number, inCallout: boolean}>}
|
|
69
|
+
* One entry per paragraph; `line` is the 1-based line where it starts.
|
|
70
|
+
*/
|
|
71
|
+
function extractParagraphs(content) {
|
|
72
|
+
const lines = content.split('\n');
|
|
73
|
+
const paragraphs = [];
|
|
74
|
+
|
|
75
|
+
let inCodeBlock = false;
|
|
76
|
+
let inCallout = false;
|
|
77
|
+
let paragraphLines = [];
|
|
78
|
+
let paragraphStartLine = 0;
|
|
79
|
+
let paragraphInCallout = false;
|
|
80
|
+
|
|
81
|
+
const flush = () => {
|
|
82
|
+
if (paragraphLines.length === 0) return;
|
|
83
|
+
const text = paragraphLines.join(' ');
|
|
84
|
+
const plain = stripInlineMarkup(text);
|
|
85
|
+
const words = plain.length === 0
|
|
86
|
+
? 0
|
|
87
|
+
: plain.split(/\s+/).filter(word => word.length > 0).length;
|
|
88
|
+
// Sentence count is a rough heuristic: terminal punctuation followed by
|
|
89
|
+
// whitespace or end-of-paragraph
|
|
90
|
+
const sentences = (plain.match(/[.!?](\s|$)/g) || []).length;
|
|
91
|
+
paragraphs.push({
|
|
92
|
+
line: paragraphStartLine,
|
|
93
|
+
text,
|
|
94
|
+
plain,
|
|
95
|
+
words,
|
|
96
|
+
chars: plain.length,
|
|
97
|
+
sentences,
|
|
98
|
+
inCallout: paragraphInCallout
|
|
99
|
+
});
|
|
100
|
+
paragraphLines = [];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < lines.length; i++) {
|
|
104
|
+
const line = lines[i];
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
|
|
107
|
+
// Fenced code blocks: skip contents entirely
|
|
108
|
+
if (/^```/.test(trimmed) || /^~~~/.test(trimmed)) {
|
|
109
|
+
flush();
|
|
110
|
+
inCodeBlock = !inCodeBlock;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (inCodeBlock) continue;
|
|
114
|
+
|
|
115
|
+
// Fenced div open (e.g. ::: {.pro-tip custom-style="pro-tip"})
|
|
116
|
+
if (/^:::\s*\{/.test(trimmed)) {
|
|
117
|
+
flush();
|
|
118
|
+
inCallout = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Fenced div close
|
|
122
|
+
if (/^:::\s*$/.test(trimmed)) {
|
|
123
|
+
flush();
|
|
124
|
+
inCallout = false;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Non-prose block-level lines end the current paragraph
|
|
129
|
+
if (
|
|
130
|
+
/^#{1,6}\s/.test(line) || // headings
|
|
131
|
+
/^\s*[-*+]\s/.test(line) || // bullet list items
|
|
132
|
+
/^\s*\d+\.\s/.test(line) || // numbered list items
|
|
133
|
+
/^\s{0,3}>/.test(line) || // blockquotes
|
|
134
|
+
/^\s*\|/.test(line) || // tables
|
|
135
|
+
/^\[\^[^\]]+\]:/.test(trimmed) || // footnote definitions
|
|
136
|
+
/^<!--/.test(trimmed) // HTML comments (e.g. textlint directives)
|
|
137
|
+
) {
|
|
138
|
+
flush();
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (trimmed === '') {
|
|
143
|
+
flush();
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (paragraphLines.length === 0) {
|
|
148
|
+
paragraphStartLine = i + 1;
|
|
149
|
+
paragraphInCallout = inCallout;
|
|
150
|
+
}
|
|
151
|
+
paragraphLines.push(trimmed);
|
|
152
|
+
}
|
|
153
|
+
flush();
|
|
154
|
+
|
|
155
|
+
return paragraphs;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Files exempt from paragraph-length checks: structural pages, key-takeaways
|
|
160
|
+
* collections, and scratch drafts (see lib/structure.js).
|
|
161
|
+
* @param {string} filePath - Path to chapter file (e.g. src/foo.md)
|
|
162
|
+
* @returns {boolean} True if the file should be skipped
|
|
163
|
+
*/
|
|
164
|
+
function isParagraphLengthExempt(filePath) {
|
|
165
|
+
return isNonChapter(filePath);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export {
|
|
169
|
+
extractParagraphs,
|
|
170
|
+
stripInlineMarkup,
|
|
171
|
+
isParagraphLengthExempt,
|
|
172
|
+
MAX_PARAGRAPH_WORDS
|
|
173
|
+
};
|
package/lib/revision.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The build stamp: "<semver> (<short sha>)", e.g. "1.2.0 (0123abcd)".
|
|
2
|
+
//
|
|
3
|
+
// It's passed to pandoc as the `date` metadata and filled into any
|
|
4
|
+
// [...]{.revision} span by filters/revision.lua, so builds never rewrite
|
|
5
|
+
// tracked files. $BOOK_KIT_REVISION overrides it (reproducible parity builds).
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { pathFor, loadConfig } from './config.js';
|
|
10
|
+
|
|
11
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
12
|
+
const DEFAULT_VERSION = '0.1.0';
|
|
13
|
+
|
|
14
|
+
export function readVersion(path) {
|
|
15
|
+
if (!existsSync(path)) return DEFAULT_VERSION;
|
|
16
|
+
const raw = readFileSync(path, 'utf8').trim();
|
|
17
|
+
if (!SEMVER.test(raw)) {
|
|
18
|
+
console.warn(`Warning: ${path} holds "${raw}", which isn't semver; using ${DEFAULT_VERSION}`);
|
|
19
|
+
return DEFAULT_VERSION;
|
|
20
|
+
}
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function gitSha(cwd) {
|
|
25
|
+
try {
|
|
26
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
27
|
+
} catch {
|
|
28
|
+
return 'unknown';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function revision(config = loadConfig()) {
|
|
33
|
+
if (process.env.BOOK_KIT_REVISION) return process.env.BOOK_KIT_REVISION;
|
|
34
|
+
return `${readVersion(pathFor('version', config))} (${gitSha(config.root)})`;
|
|
35
|
+
}
|
package/lib/structure.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// What counts as a chapter. Structural pages (copyright, dedication, about the
|
|
2
|
+
// author, ...), section key-takeaways collections, and scratch drafts aren't
|
|
3
|
+
// narrative chapters, so the prose validators and audits skip them.
|
|
4
|
+
//
|
|
5
|
+
// structure.structural_slugs entries match a file's slug exactly, or by
|
|
6
|
+
// prefix when they end in "*" ("appendix*").
|
|
7
|
+
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { loadConfigOrDefaults } from './config.js';
|
|
10
|
+
|
|
11
|
+
export function slugOf(file) {
|
|
12
|
+
return path.basename(file, '.md');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function matches(slug, pattern) {
|
|
16
|
+
return pattern.endsWith('*') ? slug.startsWith(pattern.slice(0, -1)) : slug === pattern;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isStructuralSlug(slug, config = loadConfigOrDefaults()) {
|
|
20
|
+
return config.structure.structural_slugs.some((p) => matches(slug, p));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isStructural(file, config = loadConfigOrDefaults()) {
|
|
24
|
+
return isStructuralSlug(slugOf(file), config);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isTakeaways(file, config = loadConfigOrDefaults()) {
|
|
28
|
+
return slugOf(file).endsWith(config.structure.takeaways_suffix);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A draft that isn't part of the book yet (structure.draft_suffix).
|
|
32
|
+
export function isScratch(file, config = loadConfigOrDefaults()) {
|
|
33
|
+
return slugOf(file).endsWith(config.structure.draft_suffix ?? '-scratch');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Not a narrative chapter: skip it in prose audits.
|
|
37
|
+
export function isNonChapter(file, config = loadConfigOrDefaults()) {
|
|
38
|
+
return isStructural(file, config) || isTakeaways(file, config) || isScratch(file, config);
|
|
39
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* textlint formatter that reports findings as GitHub Actions workflow
|
|
3
|
+
* commands (`::error file=…,line=…::message`), so each one becomes an
|
|
4
|
+
* annotation on the right file and line.
|
|
5
|
+
*
|
|
6
|
+
* The `prose` recipe uses it when GITHUB_ACTIONS is set. Without it,
|
|
7
|
+
* textlint's stylish output gets parsed by the eslint-stylish problem
|
|
8
|
+
* matcher that actions/setup-node registers, which misreads it: its file
|
|
9
|
+
* pattern matches any non-blank line (so a finding becomes the "file" for
|
|
10
|
+
* the next one, annotated on `.github`), the `✓` fixable marker keeps
|
|
11
|
+
* fixable findings from matching at all, and multi-line messages are
|
|
12
|
+
* dropped. Workflow commands carry the file with each finding and escape
|
|
13
|
+
* newlines, so none of that applies.
|
|
14
|
+
*/
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const LEVELS = { 1: 'warning', 2: 'error', 3: 'notice' };
|
|
18
|
+
|
|
19
|
+
// Escaping from @actions/core: data escapes %, CR, LF; properties also , and :
|
|
20
|
+
const escapeData = (s) => String(s)
|
|
21
|
+
.replace(/%/g, '%25')
|
|
22
|
+
.replace(/\r/g, '%0D')
|
|
23
|
+
.replace(/\n/g, '%0A');
|
|
24
|
+
const escapeProperty = (s) => escapeData(s)
|
|
25
|
+
.replace(/:/g, '%3A')
|
|
26
|
+
.replace(/,/g, '%2C');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('@textlint/types').TextlintResult[]} results
|
|
30
|
+
* @returns {string} One workflow command per finding, then a count
|
|
31
|
+
*/
|
|
32
|
+
export default function format(results) {
|
|
33
|
+
// Annotation paths are relative to the repository root, which may sit
|
|
34
|
+
// above the book (the workflow's working-directory input).
|
|
35
|
+
const root = process.env.GITHUB_WORKSPACE || process.cwd();
|
|
36
|
+
const lines = [];
|
|
37
|
+
let count = 0;
|
|
38
|
+
|
|
39
|
+
for (const { filePath, messages } of results) {
|
|
40
|
+
const file = path.relative(root, filePath);
|
|
41
|
+
for (const m of messages) {
|
|
42
|
+
count++;
|
|
43
|
+
const level = LEVELS[m.severity] || 'error';
|
|
44
|
+
const props = [
|
|
45
|
+
`file=${escapeProperty(file)}`,
|
|
46
|
+
`line=${m.line}`,
|
|
47
|
+
`col=${m.column}`,
|
|
48
|
+
`title=${escapeProperty(`textlint (${m.ruleId})`)}`,
|
|
49
|
+
].join(',');
|
|
50
|
+
let message = m.message;
|
|
51
|
+
if (m.fix) message += `${/[.!?]$/.test(message) ? '' : '.'} Fixable with \`just prose --fix\`.`;
|
|
52
|
+
lines.push(`::${level} ${props}::${escapeData(message)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (count) lines.push(`${count} prose problem${count === 1 ? '' : 's'}`);
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|