book-kit 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +72 -0
  3. package/README.md +251 -2
  4. package/bin/book-kit.js +115 -0
  5. package/commands/_emoji_io.py +32 -0
  6. package/commands/audit-chapter-lengths.js +244 -0
  7. package/commands/check-epub-size.js +68 -0
  8. package/commands/check-pdf-page-count.js +162 -0
  9. package/commands/composite-kindle-emoji.py +85 -0
  10. package/commands/composite-print-emoji.py +98 -0
  11. package/commands/conventions.js +76 -0
  12. package/commands/convert-pdfx.sh +194 -0
  13. package/commands/download-icc-profile.sh +105 -0
  14. package/commands/fonts.py +212 -0
  15. package/commands/generate-reference-docx.py +344 -0
  16. package/commands/kindle-fonts.js +173 -0
  17. package/commands/links-to-footnotes.js +366 -0
  18. package/commands/markdownlint.js +25 -0
  19. package/commands/minify-css.js +43 -0
  20. package/commands/optimize-images.js +73 -0
  21. package/commands/parity.sh +150 -0
  22. package/commands/postprocess-epub.js +541 -0
  23. package/commands/prep-print-src.js +77 -0
  24. package/commands/prose.js +38 -0
  25. package/commands/release-files.js +89 -0
  26. package/commands/reorder-print-frontmatter.js +79 -0
  27. package/commands/strip-page-rules.js +1168 -0
  28. package/commands/strip-property-rules.js +93 -0
  29. package/commands/update-key-takeaways.js +314 -0
  30. package/commands/validate.js +283 -0
  31. package/configs/markdownlint-cli2.jsonc +98 -0
  32. package/configs/textlintrc.json +22 -0
  33. package/css/theme/01-tokens.css +83 -0
  34. package/css/theme/02-fonts.css +79 -0
  35. package/css/theme/03-prose.css +53 -0
  36. package/css/theme/04-base.css +226 -0
  37. package/css/theme/05-components.css +546 -0
  38. package/css/theme/06-utilities.css +40 -0
  39. package/css/theme/07-ereader.css +245 -0
  40. package/css/theme/08-epub.css +865 -0
  41. package/css/theme/09-back-matter.css +47 -0
  42. package/css/theme/10-dark.css +302 -0
  43. package/css/theme/11-print.css +1229 -0
  44. package/css/theme.css +38 -0
  45. package/defaults/book.config.yml +318 -0
  46. package/defaults/pandoc-base.yml +19 -0
  47. package/defaults/pandoc-docx.yml +6 -0
  48. package/defaults/pandoc-epub-fonts.yml +6 -0
  49. package/defaults/pandoc-epub.yml +15 -0
  50. package/defaults/pandoc-kindle.yml +6 -0
  51. package/emoji/1f382.png +0 -0
  52. package/emoji/1f389.png +0 -0
  53. package/emoji/1f440.png +0 -0
  54. package/emoji/1f44d.png +0 -0
  55. package/emoji/1f44f.png +0 -0
  56. package/emoji/1f454.png +0 -0
  57. package/emoji/1f4a1.png +0 -0
  58. package/emoji/1f4bb.png +0 -0
  59. package/emoji/1f602.png +0 -0
  60. package/emoji/1f603.png +0 -0
  61. package/emoji/1f604.png +0 -0
  62. package/emoji/1f60a.png +0 -0
  63. package/emoji/1f60f.png +0 -0
  64. package/emoji/1f64c.png +0 -0
  65. package/emoji/1f64f.png +0 -0
  66. package/emoji/1f914.png +0 -0
  67. package/emoji/1f923.png +0 -0
  68. package/emoji/2714.png +0 -0
  69. package/emoji/274c.png +0 -0
  70. package/filters/add-div-titles.lua +196 -0
  71. package/filters/arc-notice.lua +36 -0
  72. package/filters/body-emoji-images.lua +48 -0
  73. package/filters/callout-classes.lua +40 -0
  74. package/filters/lib/book_kit.lua +110 -0
  75. package/filters/revision.lua +17 -0
  76. package/filters/strip-comments.lua +40 -0
  77. package/filters/strip-emoji-kindle.lua +134 -0
  78. package/fonts/LICENSE-source-code-pro.md +93 -0
  79. package/fonts/LICENSE-source-sans.md +93 -0
  80. package/fonts/LICENSE-source-serif.md +93 -0
  81. package/fonts/recipe.json +41 -0
  82. package/just/book-kit.just +229 -0
  83. package/lib/acronyms.js +47 -0
  84. package/lib/callouts.js +17 -0
  85. package/lib/config.js +211 -0
  86. package/lib/is-main.js +21 -0
  87. package/lib/lint.js +148 -0
  88. package/lib/pandoc.js +148 -0
  89. package/lib/paragraphs.js +173 -0
  90. package/lib/revision.js +35 -0
  91. package/lib/structure.js +39 -0
  92. package/lib/textlint-github-formatter.js +58 -0
  93. package/lib/validate.js +202 -0
  94. package/package.json +98 -4
  95. package/presets/business-nonfiction/CONVENTIONS.md +35 -0
  96. package/presets/business-nonfiction/preset.yml +128 -0
  97. package/profiles/pdfx-def.ps +42 -0
  98. package/requirements.txt +14 -0
  99. package/rules/no-ai-like-patterns.js +209 -0
  100. package/rules/no-assumptive.js +76 -0
  101. package/rules/no-bare-acronyms.js +129 -0
  102. package/rules/no-cliches.js +544 -0
  103. package/rules/no-cultural-idioms.js +422 -0
  104. package/rules/no-vague-intensifiers.js +94 -0
  105. package/templates/template-epub.html +79 -0
  106. package/templates/template.html +73 -0
  107. package/toolchain.json +42 -0
  108. package/validators/acronym-expansion.js +94 -0
  109. package/validators/bold-keywords.js +56 -0
  110. package/validators/callout-balance.js +74 -0
  111. package/validators/callout-divs.js +66 -0
  112. package/validators/callout-labels.js +102 -0
  113. package/validators/chapter-lengths.js +14 -0
  114. package/validators/chapter.js +57 -0
  115. package/validators/cross-references.js +45 -0
  116. package/validators/doubled-words.js +76 -0
  117. package/validators/duplicate-headings.js +52 -0
  118. package/validators/em-dashes.js +73 -0
  119. package/validators/escaped-smallcaps.js +46 -0
  120. package/validators/footnotes.js +78 -0
  121. package/validators/heading-hierarchy.js +58 -0
  122. package/validators/hypothetical-hooks.js +103 -0
  123. package/validators/index-integrity.js +28 -0
  124. package/validators/index.js +73 -0
  125. package/validators/list-punctuation.js +123 -0
  126. package/validators/min-opener-length.js +87 -0
  127. package/validators/no-bare-url-link-text.js +38 -0
  128. package/validators/no-todo-markers.js +49 -0
  129. package/validators/no-transition-todos.js +34 -0
  130. package/validators/number-range-dashes.js +72 -0
  131. package/validators/paragraph-length.js +62 -0
  132. package/validators/quote-punctuation.js +85 -0
  133. package/validators/section-intro-final-lead.js +90 -0
  134. package/validators/section-intro-has-lead.js +41 -0
  135. package/validators/section-intro-no-tldr.js +37 -0
  136. package/validators/section-intros.js +33 -0
  137. package/validators/sentence-starters.js +132 -0
  138. package/validators/smallcaps.js +67 -0
  139. package/validators/tldr-length.js +58 -0
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ };
@@ -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
+ }
@@ -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
+ }