@heroiclands/package-build 22.17.0 → 22.17.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ **Prose review**
8
+
9
+ - Prose lint focuses on difficult sentences by default and skips tables and code; simpler-word suggestions remain available on request.
10
+ - Prose score reports each note's readability and lets a content package define an advisory range for its writing.
11
+
3
12
  ## 22.17.0
4
13
 
5
14
  ### Minor Changes
@@ -97,7 +97,7 @@ import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
97
97
  import { schemaSubtypeOf } from "../engine/subtype-registry.mjs";
98
98
  import { NOTE_VOCABULARY } from "../engine/note-vocabulary.mjs";
99
99
  import { checkFormatting, checkPrettierConventions, lintMarkdown } from "../engine/prose-lint.mjs";
100
- import { lintProse } from "../engine/readability-lint.mjs";
100
+ import { lintProse, scoreProseTree } from "../engine/readability-lint.mjs";
101
101
  import { loadPackageBuildConfig } from "../config.mjs";
102
102
  import {
103
103
  authoredFrontmatter,
@@ -240,14 +240,14 @@ export function registerContentCommands(cli) {
240
240
  .command(addressesCommand());
241
241
  }
242
242
 
243
- /** Opt-in readability and plain-language suggestions for Markdown notes. */
243
+ /** Optional sentence findings and note-level readability reports. */
244
244
  function proseCommand() {
245
245
  return {
246
246
  command: "prose <action> [path]",
247
247
  describe: "Analyze note prose on demand",
248
248
  builder: (yargs) =>
249
249
  yargs
250
- .positional("action", { choices: ["lint"], describe: "Analyze prose" })
250
+ .positional("action", { choices: ["lint", "score"], describe: "Analyze prose" })
251
251
  .positional("path", {
252
252
  type: "string",
253
253
  describe: "One Markdown file or a content tree",
@@ -257,17 +257,97 @@ function proseCommand() {
257
257
  type: "number",
258
258
  describe: "Readability algorithms required (1–7)",
259
259
  })
260
- .option("min-words", { type: "number", describe: "Minimum words per sentence" }),
260
+ .option("min-words", {
261
+ type: "number",
262
+ describe: "Minimum words per sentence or note",
263
+ })
264
+ .option("rules", {
265
+ choices: ["readability", "simplify", "all"],
266
+ describe: "Lint rule set",
267
+ })
268
+ .option("fail-on-warning", {
269
+ type: "boolean",
270
+ default: false,
271
+ describe: "Exit unsuccessfully when lint reports suggestions",
272
+ })
273
+ .option("fail-outside", {
274
+ type: "boolean",
275
+ default: false,
276
+ describe: "Exit unsuccessfully when a scored note is outside a configured band",
277
+ }),
261
278
  handler: async (argv) => {
262
279
  try {
263
280
  const config = loadPackConfig();
264
- const defaults = loadPackageBuildConfig().proseLint;
281
+ const defaults = loadPackageBuildConfig();
282
+ const root = argv.path ?? config.paths.content;
283
+ const displayFile = (file) => path.relative(process.cwd(), path.resolve(file));
284
+ if (argv.action === "score") {
285
+ if (
286
+ argv.age !== undefined ||
287
+ argv.threshold !== undefined ||
288
+ argv.rules !== undefined ||
289
+ argv.failOnWarning
290
+ ) {
291
+ throw new Error(
292
+ "--age, --threshold, --rules, and --fail-on-warning apply only to prose lint",
293
+ );
294
+ }
295
+ const minWords = argv.minWords ?? defaults.proseScore.minWords;
296
+ if (!Number.isInteger(minWords) || minWords < 1) {
297
+ throw new Error("minWords must be a positive integer");
298
+ }
299
+ const result = await scoreProseTree(
300
+ root,
301
+ { ...defaults.proseScore, minWords },
302
+ config.skipDirectories,
303
+ );
304
+ const fixed = (number, places = 1) => number.toFixed(places);
305
+ for (const note of result.notes) {
306
+ if (note.insufficient) {
307
+ console.log(
308
+ `${displayFile(note.file)}: insufficient prose (${note.words}/${minWords} words; coverage=${fixed(note.coveragePercent)}%)`,
309
+ );
310
+ continue;
311
+ }
312
+ const metrics = note.metrics;
313
+ const detail =
314
+ `flesch=${fixed(metrics.flesch)} syl/word=${fixed(metrics.syllablesPerWord, 2)} ` +
315
+ `mean=${fixed(metrics.meanSentenceWords)}w longest=${note.longestSentenceWords}w ` +
316
+ `unfamiliar=${fixed(metrics.unfamiliarWordPercent)}% ` +
317
+ `nominalizations/1k=${fixed(metrics.nominalizationsPer1000Words)} ` +
318
+ `words=${note.words} coverage=${fixed(note.coveragePercent)}%`;
319
+ if (note.violations.length) {
320
+ emitDiagnostic({
321
+ file: displayFile(note.file),
322
+ severity: "warning",
323
+ message: `prose-score/band: ${detail}; ${note.violations.join(", ")}`,
324
+ });
325
+ } else {
326
+ console.log(`${displayFile(note.file)}: ${detail}`);
327
+ }
328
+ }
329
+ const aggregate = result.summary;
330
+ console.log(
331
+ `Total: ${aggregate.scored} scored, ${aggregate.insufficient} insufficient, ` +
332
+ `${aggregate.outside} outside band; ${aggregate.words} words; ` +
333
+ `pooled flesch=${aggregate.flesch === null ? "n/a" : fixed(aggregate.flesch)} ` +
334
+ `syl/word=${aggregate.words ? fixed(aggregate.syllables / aggregate.words, 2) : "n/a"} ` +
335
+ `mean=${aggregate.sentences ? fixed(aggregate.words / aggregate.sentences) : "n/a"}w ` +
336
+ `unfamiliar=${aggregate.words ? fixed((100 * aggregate.unfamiliar) / aggregate.words) : "n/a"}% ` +
337
+ `nominalizations/1k=${aggregate.words ? fixed((1000 * aggregate.nominalizations) / aggregate.words) : "n/a"}`,
338
+ );
339
+ if (argv.failOutside && aggregate.outside) process.exitCode = 1;
340
+ return;
341
+ }
342
+ if (argv.failOutside) throw new Error("--fail-outside applies only to prose score");
265
343
  const options = {
266
- age: argv.age ?? defaults.age,
267
- threshold: argv.threshold ?? defaults.threshold,
268
- minWords: argv.minWords ?? defaults.minWords,
344
+ age: argv.age ?? defaults.proseLint.age,
345
+ threshold: argv.threshold ?? defaults.proseLint.threshold,
346
+ minWords: argv.minWords ?? defaults.proseLint.minWords,
347
+ rules: argv.rules ?? defaults.proseLint.rules,
269
348
  };
270
349
  for (const [key, value] of Object.entries(options)) {
350
+ if (key === "rules") continue;
271
351
  if (
272
352
  !Number.isInteger(value) ||
273
353
  value < 1 ||
@@ -278,13 +358,12 @@ function proseCommand() {
278
358
  );
279
359
  }
280
360
  }
281
- const findings = await lintProse(
282
- argv.path ?? config.paths.content,
283
- options,
284
- config.skipDirectories,
285
- );
286
- for (const finding of findings) emitDiagnostic(finding);
361
+ const findings = await lintProse(root, options, config.skipDirectories);
362
+ for (const finding of findings) {
363
+ emitDiagnostic({ ...finding, file: displayFile(finding.file) });
364
+ }
287
365
  log.info(`${findings.length} prose suggestion(s).`);
366
+ if (argv.failOnWarning && findings.length) process.exitCode = 1;
288
367
  } catch (err) {
289
368
  reportFailure(err);
290
369
  process.exitCode = 1;
package/config.mjs CHANGED
@@ -76,6 +76,7 @@ const SECTION_KEYS = [
76
76
  "container",
77
77
  "e2e",
78
78
  "proseLint",
79
+ "proseScore",
79
80
  ];
80
81
 
81
82
  /**
@@ -528,7 +529,8 @@ function normalizeExceptions(value, field, where) {
528
529
  *
529
530
  * @typedef {object} PackageBuildConfig
530
531
  * @property {string} rootDir The repository root, from package-build.
531
- * @property {{age: number, threshold: number, minWords: number}} proseLint Optional prose analysis settings.
532
+ * @property {{age: number, threshold: number, minWords: number, rules: string}} proseLint Optional prose lint settings.
533
+ * @property {{minWords: number, bands: Record<string, {min?: number, max?: number}>}} proseScore Optional note score settings.
532
534
  * @property {string} packageKind `systems` or `modules`.
533
535
  * @property {string} packageId The Foundry package id.
534
536
  * @property {string} artifact Derived: `system` or `module`.
@@ -693,9 +695,20 @@ export function resolvePackageBuildConfig(shared) {
693
695
 
694
696
  const proseLint = section.proseLint ?? {};
695
697
  if (!isMapping(proseLint)) fail("packageBuild.proseLint", "must be a mapping");
696
- rejectUnknownKeys(proseLint, ["age", "threshold", "minWords"], "packageBuild.proseLint.");
697
- const proseOptions = { age: 21, threshold: 5, minWords: 8 };
698
+ rejectUnknownKeys(
699
+ proseLint,
700
+ ["age", "threshold", "minWords", "rules"],
701
+ "packageBuild.proseLint.",
702
+ );
703
+ const proseOptions = { age: 21, threshold: 5, minWords: 8, rules: "readability" };
698
704
  for (const [key, value] of Object.entries(proseLint)) {
705
+ if (key === "rules") {
706
+ if (!["readability", "simplify", "all"].includes(value)) {
707
+ fail("packageBuild.proseLint.rules", "must be readability, simplify, or all");
708
+ }
709
+ proseOptions.rules = value;
710
+ continue;
711
+ }
699
712
  const upper = key === "threshold" ? 7 : Infinity;
700
713
  if (!Number.isInteger(value) || value < 1 || value > upper) {
701
714
  fail(
@@ -708,6 +721,43 @@ export function resolvePackageBuildConfig(shared) {
708
721
  proseOptions[key] = value;
709
722
  }
710
723
 
724
+ const proseScore = section.proseScore ?? {};
725
+ if (!isMapping(proseScore)) fail("packageBuild.proseScore", "must be a mapping");
726
+ rejectUnknownKeys(proseScore, ["minWords", "bands"], "packageBuild.proseScore.");
727
+ const scoreMinWords = proseScore.minWords ?? 80;
728
+ if (!Number.isInteger(scoreMinWords) || scoreMinWords < 1) {
729
+ fail("packageBuild.proseScore.minWords", "must be a positive integer");
730
+ }
731
+ const bands = proseScore.bands ?? {};
732
+ if (!isMapping(bands)) fail("packageBuild.proseScore.bands", "must be a mapping");
733
+ const scoreMetrics = [
734
+ "flesch",
735
+ "syllablesPerWord",
736
+ "meanSentenceWords",
737
+ "longestSentenceWords",
738
+ "unfamiliarWordPercent",
739
+ "nominalizationsPer1000Words",
740
+ ];
741
+ rejectUnknownKeys(bands, scoreMetrics, "packageBuild.proseScore.bands.");
742
+ const scoreBands = {};
743
+ for (const [metric, band] of Object.entries(bands)) {
744
+ const where = `packageBuild.proseScore.bands.${metric}`;
745
+ if (!isMapping(band)) fail(where, "must be a mapping");
746
+ rejectUnknownKeys(band, ["min", "max"], `${where}.`);
747
+ if (band.min === undefined && band.max === undefined) {
748
+ fail(where, "must specify min, max, or both");
749
+ }
750
+ for (const [limit, value] of Object.entries(band)) {
751
+ if (typeof value !== "number" || !Number.isFinite(value)) {
752
+ fail(`${where}.${limit}`, "must be a finite number");
753
+ }
754
+ }
755
+ if (band.min !== undefined && band.max !== undefined && band.min > band.max) {
756
+ fail(where, "min must not exceed max");
757
+ }
758
+ scoreBands[metric] = Object.freeze({ ...band });
759
+ }
760
+
711
761
  if (section.assets !== undefined && !Array.isArray(section.assets)) {
712
762
  fail("packageBuild.assets", "must be a list");
713
763
  }
@@ -809,6 +859,7 @@ export function resolvePackageBuildConfig(shared) {
809
859
  return Object.freeze({
810
860
  rootDir: shared.rootDir,
811
861
  proseLint: Object.freeze(proseOptions),
862
+ proseScore: Object.freeze({ minWords: scoreMinWords, bands: Object.freeze(scoreBands) }),
812
863
  // Where the package is assembled before it is zipped or deployed. Every
813
864
  // asset destination is relative to it, so a repository's table says
814
865
  // `lang`, not `build/stage/lang` — the latter is what each consumer's
package/docs/commands.md CHANGED
@@ -1712,82 +1712,118 @@ $ package-build docs item-fields --out docs/item-fields.md --title "Demo Item Fi
1712
1712
 
1713
1713
  [Configuration](configuration.md).
1714
1714
 
1715
- ### `package-build prose lint [path]`
1715
+ ### `package-build prose lint [path]` and `prose score [path]`
1716
1716
 
1717
1717
  **NAME**
1718
1718
 
1719
- Analyze note prose for readability and simpler wording.
1719
+ Review sentence readability or score each note against a package's prose bands.
1720
1720
 
1721
1721
  **SYNOPSIS**
1722
1722
 
1723
1723
  ```text
1724
- package-build prose lint [path] [--age <years>] [--threshold <count>] [--min-words <count>]
1724
+ package-build prose lint [path] [--age <years>] [--threshold <count>] [--min-words <count>] [--rules readability|simplify|all] [--fail-on-warning]
1725
+ package-build prose score [path] [--min-words <count>] [--fail-outside]
1725
1726
  ```
1726
1727
 
1727
1728
  **DESCRIPTION**
1728
1729
 
1729
- Run this command from the package root to review the readable prose in one
1730
- Markdown note or a content tree. Pass a note path while editing that note;
1731
- omit it to review the configured `paths.content` tree. A directory path scans
1732
- its Markdown files recursively and honors `skipDirectories`. A file path
1733
- analyzes that file alone, even if it sits in an excluded directory. The command
1734
- reads source files and writes nothing. It is an optional editorial check:
1735
- `package-build lint` and the normal lint scripts do not run it.
1730
+ Run the `lint` or `score` action from the package root. Pass a Markdown note while editing;
1731
+ omit the path to read the configured `paths.content` tree. A directory path
1732
+ scans Markdown files recursively and honors `skipDirectories`. A file path
1733
+ analyzes that file alone, even in an excluded directory. Both actions read
1734
+ source files and write nothing. They are optional editorial checks and are
1735
+ outside `package-build lint` and the normal build.
1736
1736
 
1737
1737
  Set defaults in `package-build.config.yaml` under `packageBuild.proseLint`:
1738
1738
 
1739
1739
  ```yaml
1740
1740
  packageBuild:
1741
- proseLint: { age: 21, threshold: 5, minWords: 8 }
1741
+ proseLint: { age: 21, threshold: 5, minWords: 8, rules: readability }
1742
+ proseScore:
1743
+ minWords: 80
1744
+ bands:
1745
+ flesch: { min: 45, max: 80 }
1746
+ syllablesPerWord: { max: 1.65 }
1742
1747
  ```
1743
1748
 
1744
- No configuration is required; the values above are the defaults. `age` is the
1749
+ No configuration is required. `proseLint` uses the defaults shown. The
1750
+ `proseScore` default is `minWords: 80` with no bands; the example bands express
1751
+ one package's editorial preference, not a toolchain standard. `age` is the
1745
1752
  intended reader's age in years. `threshold` is the number of seven readability
1746
1753
  algorithms that must classify a sentence as difficult before it is reported.
1747
1754
  For example, a threshold of 5 reports a sentence if five or more algorithms
1748
- flag it; a threshold of 7 reports only unanimous findings. `minWords` excludes
1749
- shorter sentences from readability analysis. Each setting is a positive integer,
1750
- and `threshold` must be at most 7. CLI options override individual settings:
1751
- `--min-words` overrides `minWords`, while `--age` and `--threshold` override
1752
- their matching keys.
1753
-
1754
- The command analyzes Markdown prose with `retext-english`,
1755
- `retext-readability`, and `retext-simplify`; `retext-stringify` completes the
1756
- text pipeline. YAML frontmatter, code blocks, wikilink addresses, and inline
1757
- expressions are excluded. Each suggestion is a warning with a file, line,
1755
+ flag it; a threshold of 7 reports only unanimous findings. Lint's `minWords`
1756
+ excludes shorter sentences. `rules: readability` reports difficult sentences;
1757
+ `simplify` reports wording suggestions; `all` reports both. The CLI options
1758
+ override individual settings. Lint numeric settings are positive integers;
1759
+ `threshold` is at most 7.
1760
+
1761
+ Lint analyzes Markdown paragraphs, including those in blockquotes, with `retext-english` and the selected
1762
+ readability or simplify rules; `retext-stringify` completes the text pipeline.
1763
+ Frontmatter, headings, lists, GFM tables, code blocks and inline code, image
1764
+ embeds, wikilinks, and inline expressions are excluded. The body of an ordinary
1765
+ directive such as `:::info` remains prose; SQL directive bodies are excluded.
1766
+ Each suggestion is a warning with a file, line,
1758
1767
  column, rule ID, confidence, offending sentence, and `expected` replacement
1759
1768
  list. The source location marks the difficult sentence or the specific phrase
1760
1769
  that could be simplified. Readability confidence is the number of algorithms
1761
1770
  that found the sentence difficult, out of seven. Simplify suggestions have no
1762
1771
  numeric confidence score, so they say `unscored`. `expected` lists suggested
1763
1772
  replacements; `[]` means the rule offers no replacement and may recommend
1764
- deleting a word or rewriting a sentence. Suggestions are advisory, are never
1765
- applied automatically, and do not make the command fail. Unreadable paths and
1766
- invalid options do.
1773
+ deleting a word or rewriting a sentence. Suggestions are advisory and are
1774
+ never applied automatically. `--fail-on-warning` makes a selected rule's
1775
+ suggestions an explicit exit-code gate.
1776
+
1777
+ Score uses the same paragraph selection. It reports Flesch reading ease,
1778
+ syllables per word, mean and longest sentence length, unfamiliar-word percentage
1779
+ against the Dale–Chall list, and nominalizations per thousand words. A
1780
+ nominalization is counted by its word ending (`-tion`, `-sion`, `-ment`,
1781
+ `-ness`, `-ity`, `-ance`, `-ence`, or `-ism`); this is a mechanical count, not a
1782
+ grammar judgment. Coverage is the percentage of readable body words included
1783
+ in the score: headings and lists count toward the body but are not scored.
1784
+ Review low-coverage notes in context. A note with fewer than score's `minWords`
1785
+ is reported as insufficient and is not compared with bands.
1786
+
1787
+ Under `packageBuild.proseScore.bands`, any of `flesch`, `syllablesPerWord`,
1788
+ `meanSentenceWords`, `longestSentenceWords`, `unfamiliarWordPercent`, and
1789
+ `nominalizationsPer1000Words` may set `min`, `max`, or both. Limits are finite
1790
+ numbers and a minimum cannot exceed a maximum. Violations are warnings. The
1791
+ directory summary pools sentence, word, and syllable counts across sufficiently
1792
+ long notes before computing Flesch; it is not an average of note scores.
1793
+ `--fail-outside` makes band violations an explicit gate. No band is enforced
1794
+ until the package declares it.
1767
1795
 
1768
1796
  **OPTIONS**
1769
1797
 
1770
- | Input | Default | Meaning |
1771
- | ------------- | -------------------------- | ---------------------------------------- |
1772
- | `path` | configured `paths.content` | Markdown file or content tree |
1773
- | `--age` | 21 | Reader age in years |
1774
- | `--threshold` | 5 | Number of readability algorithms (1–7) |
1775
- | `--min-words` | 8 | Minimum words for readability assessment |
1798
+ | Input | Default | Meaning |
1799
+ | ------------------- | -------------------------- | -------------------------------------------- |
1800
+ | `path` | configured `paths.content` | Markdown file or content tree |
1801
+ | `--age` | 21 | Lint reader age in years |
1802
+ | `--threshold` | 5 | Lint readability algorithms required (1–7) |
1803
+ | `--rules` | `readability` | Lint rules: `readability`, `simplify`, `all` |
1804
+ | `--min-words` | 8 lint; 80 score | Minimum words per sentence or note |
1805
+ | `--fail-on-warning` | false | Fail lint on selected rule findings |
1806
+ | `--fail-outside` | false | Fail score when a note crosses a band |
1776
1807
 
1777
1808
  **EXIT STATUS**
1778
1809
 
1779
- 0 after reporting suggestions; 1 for invalid options or unreadable paths.
1810
+ 0 after an advisory report; 1 for invalid options, unreadable paths, or an
1811
+ explicit failure condition.
1780
1812
 
1781
1813
  **EXAMPLES**
1782
1814
 
1783
1815
  ```bash
1784
1816
  package-build prose lint assets/content/Lore/Harbor.md
1817
+ package-build prose lint assets/content/Lore/Harbor.md --rules simplify
1818
+ package-build prose lint assets/content/Lore/Harbor.md --fail-on-warning
1785
1819
  package-build prose lint
1786
1820
  package-build prose lint assets/content --age 18 --threshold 6 --min-words 10
1821
+ package-build prose score assets/content/Lore/Harbor.md
1822
+ package-build prose score assets/content/Lore --fail-outside
1787
1823
  ```
1788
1824
 
1789
- For a note whose fifth line says `The utilization is very high.`, the command
1790
- can report:
1825
+ For a note whose fifth line says `The utilization is very high.`, lint with
1826
+ `--rules simplify` can report:
1791
1827
 
1792
1828
  ```text
1793
1829
  assets/content/Lore/Harbor.md:5:5: warning: retext-simplify/utilization: confidence=unscored; sentence="The utilization is very high."; expected=["use"]; Unexpected `utilization`, use `use` instead
@@ -1457,38 +1457,65 @@ packageBuild:
1457
1457
  envPrefix: SOHL
1458
1458
  ```
1459
1459
 
1460
- | Key | Type | Required | Default |
1461
- | ------------------------------------------------------------------------------------------ | ------------------------------------------- | -------- | -------------------------------------- |
1462
- | [`packageBuild.stageDir`](#packagebuildstagedir-and-packagebuildassets) | string | no | `build/stage` |
1463
- | [`packageBuild.assets`](#packagebuildstagedir-and-packagebuildassets) | array | no | `[]` |
1464
- | [`packageBuild.assetTransform`](#packagebuildassettransform-and-packagebuildmanifestflags) | string (path to a module) | no | `null` |
1465
- | [`packageBuild.manifest`](#packagebuildmanifest) | object, pass-through | no | `{}` |
1466
- | [`packageBuild.manifestFlags`](#packagebuildassettransform-and-packagebuildmanifestflags) | string (path to a module) | no | `null` |
1467
- | [`packageBuild.schema`](#packagebuildschema) | object (`{documentType: {from, registry}}`) | no | `[]` |
1468
- | [`packageBuild.clean`](#packagebuildclean) | object | no | `{extra: []}` |
1469
- | [`packageBuild.lang`](#packagebuildlang) | object | no | see below |
1470
- | [`packageBuild.deploy`](#packagebuilddeploy) | object | no | `{envPrefix: "SOHL"}` |
1471
- | [`packageBuild.release`](#packagebuildrelease) | object | no | `{artifact: <from packageKind>}` |
1472
- | [`packageBuild.bundle`](#packagebuildbundle) | object | no | `{entry: "<foundryPackage>.mjs"}` |
1473
- | [`packageBuild.container`](#packagebuildcontainer) | object | no | see below |
1474
- | [`packageBuild.e2e`](#packagebuilde2e) | object | no | see below |
1475
- | [`packageBuild.proseLint`](#packagebuildproselint) | object | no | `{age: 21, threshold: 5, minWords: 8}` |
1476
-
1477
- > ``package-build config: `packageBuild.<key>` is not a recognised key (expected one of: stageDir, assets, assetTransform, manifest, manifestFlags, schema, clean, lang, deploy, release, bundle, container, e2e, proseLint).``
1460
+ | Key | Type | Required | Default |
1461
+ | ------------------------------------------------------------------------------------------ | ------------------------------------------- | -------- | ---------------------------------------------------------- |
1462
+ | [`packageBuild.stageDir`](#packagebuildstagedir-and-packagebuildassets) | string | no | `build/stage` |
1463
+ | [`packageBuild.assets`](#packagebuildstagedir-and-packagebuildassets) | array | no | `[]` |
1464
+ | [`packageBuild.assetTransform`](#packagebuildassettransform-and-packagebuildmanifestflags) | string (path to a module) | no | `null` |
1465
+ | [`packageBuild.manifest`](#packagebuildmanifest) | object, pass-through | no | `{}` |
1466
+ | [`packageBuild.manifestFlags`](#packagebuildassettransform-and-packagebuildmanifestflags) | string (path to a module) | no | `null` |
1467
+ | [`packageBuild.schema`](#packagebuildschema) | object (`{documentType: {from, registry}}`) | no | `[]` |
1468
+ | [`packageBuild.clean`](#packagebuildclean) | object | no | `{extra: []}` |
1469
+ | [`packageBuild.lang`](#packagebuildlang) | object | no | see below |
1470
+ | [`packageBuild.deploy`](#packagebuilddeploy) | object | no | `{envPrefix: "SOHL"}` |
1471
+ | [`packageBuild.release`](#packagebuildrelease) | object | no | `{artifact: <from packageKind>}` |
1472
+ | [`packageBuild.bundle`](#packagebuildbundle) | object | no | `{entry: "<foundryPackage>.mjs"}` |
1473
+ | [`packageBuild.container`](#packagebuildcontainer) | object | no | see below |
1474
+ | [`packageBuild.e2e`](#packagebuilde2e) | object | no | see below |
1475
+ | [`packageBuild.proseLint`](#packagebuildproselint) | object | no | `{age: 21, threshold: 5, minWords: 8, rules: readability}` |
1476
+ | [`packageBuild.proseScore`](#packagebuildprosescore) | object | no | `{minWords: 80, bands: {}}` |
1477
+
1478
+ > ``package-build config: `packageBuild.<key>` is not a recognised key (expected one of: stageDir, assets, assetTransform, manifest, manifestFlags, schema, clean, lang, deploy, release, bundle, container, e2e, proseLint, proseScore).``
1478
1479
 
1479
1480
  ### `packageBuild.proseLint`
1480
1481
 
1481
- Optional defaults for [`package-build prose lint`](commands.md#package-build-prose-lint-path).
1482
+ Optional defaults for [`package-build prose lint`](commands.md#package-build-prose-lint-path-and-prose-score-path).
1482
1483
  `age` is the reader age, `threshold` is the number of readability algorithms
1483
1484
  required to flag a sentence (1–7), and `minWords` is the minimum sentence
1484
- length. Each setting is a positive integer; command-line options can override
1485
- them for one analysis.
1485
+ length. `rules` is `readability`, `simplify`, or `all`. Each numeric setting is
1486
+ a positive integer; command-line options can override them for one analysis.
1486
1487
 
1487
1488
  ```yaml
1488
1489
  packageBuild:
1489
- proseLint: { age: 21, threshold: 5, minWords: 8 }
1490
+ proseLint: { age: 21, threshold: 5, minWords: 8, rules: readability }
1490
1491
  ```
1491
1492
 
1493
+ ### `packageBuild.proseScore`
1494
+
1495
+ Optional defaults for [`package-build prose score`](commands.md#package-build-prose-lint-path-and-prose-score-path).
1496
+ `minWords` is the minimum number of analyzed words for a note to receive a
1497
+ score. `bands` states the package's acceptable range for any reported metric;
1498
+ there are no default bands. Each band has `min`, `max`, or both, as finite
1499
+ numbers. An omitted bound is open. The score report stays advisory unless
1500
+ `--fail-outside` is requested.
1501
+
1502
+ ```yaml
1503
+ packageBuild:
1504
+ proseScore:
1505
+ minWords: 80
1506
+ bands:
1507
+ flesch: { min: 45, max: 80 }
1508
+ syllablesPerWord: { max: 1.65 }
1509
+ meanSentenceWords: { max: 28 }
1510
+ longestSentenceWords: { max: 55 }
1511
+ unfamiliarWordPercent: { max: 35 }
1512
+ nominalizationsPer1000Words: { max: 30 }
1513
+ ```
1514
+
1515
+ Flesch is a reading-ease score, so a value below its minimum is harder prose
1516
+ and one above its maximum is simpler prose. These sample bounds are editorial
1517
+ choices for illustration. `--min-words` overrides `minWords` for one score run.
1518
+
1492
1519
  ### `packageBuild.stageDir` and `packageBuild.assets`
1493
1520
 
1494
1521
  `stageDir` is where the package is assembled before it is zipped or
@@ -8,11 +8,18 @@ import path from "node:path";
8
8
  import { unified } from "unified";
9
9
  import remarkParse from "remark-parse";
10
10
  import remarkFrontmatter from "remark-frontmatter";
11
+ import remarkGfm from "remark-gfm";
11
12
  import remarkRetext from "remark-retext";
12
13
  import retextEnglish from "retext-english";
13
14
  import retextReadability from "retext-readability";
14
15
  import retextSimplify from "retext-simplify";
15
16
  import retextStringify from "retext-stringify";
17
+ import { flesch } from "flesch";
18
+ import { syllable } from "syllable";
19
+ import { daleChall } from "dale-chall";
20
+
21
+ const FAMILIAR_WORDS = new Set(daleChall);
22
+ const NOMINALIZATION = /(?:tion|sion|ment|ness|ity|ance|ence|ism)$/i;
16
23
 
17
24
  /** Files in a content tree, excluding configured directory names. */
18
25
  export function proseFiles(root, skipDirectories = []) {
@@ -31,28 +38,84 @@ export function proseFiles(root, skipDirectories = []) {
31
38
 
32
39
  /** Keep source offsets while excluding HeroicLands inline addresses and expressions. */
33
40
  function maskMarkup(source) {
34
- return source.replace(/!?\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (match) =>
35
- match.replace(/[^\n]/g, " "),
36
- );
41
+ return source
42
+ .replace(/!?\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (match) => match.replace(/[^\n]/g, " "))
43
+ .replace(/^:::sql[^\n]*\n[\s\S]*?^:::[ \t]*$/gm, (match) => match.replace(/[^\n]/g, " "))
44
+ .replace(/^:::[^\n]*$/gm, (match) => match.replace(/[^\n]/g, " "));
37
45
  }
38
46
 
39
- /** Source-aligned findings for one Markdown note. */
40
- export async function analyzeProse(file, source, { age = 21, threshold = 5, minWords = 8 } = {}) {
41
- const text = maskMarkup(source);
42
- const virtualFile = { value: text, path: file, messages: [] };
43
- const processor = unified()
47
+ /** Select complete paragraphs while keeping their original source positions. */
48
+ function proseTree(source) {
49
+ const processor = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
50
+ const tree = processor.parse(maskMarkup(source));
51
+ const keepInline = (node) => {
52
+ if (
53
+ ["inlineCode", "image", "imageReference", "html", "footnoteReference"].includes(
54
+ node.type,
55
+ )
56
+ ) {
57
+ return false;
58
+ }
59
+ if (node.children) node.children = node.children.filter(keepInline);
60
+ return true;
61
+ };
62
+ const paragraphs = [];
63
+ const collect = (node) => {
64
+ if (node.type === "paragraph") paragraphs.push(node);
65
+ else if (node.type === "blockquote")
66
+ for (const child of node.children ?? []) collect(child);
67
+ };
68
+ for (const node of tree.children) collect(node);
69
+ tree.children = paragraphs;
70
+ for (const paragraph of tree.children)
71
+ paragraph.children = paragraph.children.filter(keepInline);
72
+ return tree;
73
+ }
74
+
75
+ /** Count readable body words, including headings and lists, for score coverage. */
76
+ function bodyWordCount(source) {
77
+ const tree = unified()
44
78
  .use(remarkParse)
45
79
  .use(remarkFrontmatter)
46
- .use(
47
- remarkRetext,
48
- unified()
49
- .use(retextEnglish)
50
- .use(retextReadability, { age, threshold: threshold / 7, minWords })
51
- .use(retextSimplify)
52
- .use(retextStringify),
53
- );
54
- await processor.run(processor.parse(virtualFile), virtualFile);
55
- return virtualFile.messages.map((message) => {
80
+ .use(remarkGfm)
81
+ .parse(maskMarkup(source));
82
+ const visit = (node) => {
83
+ if (
84
+ ["yaml", "code", "table", "html", "inlineCode", "image", "imageReference"].includes(
85
+ node.type,
86
+ )
87
+ ) {
88
+ return 0;
89
+ }
90
+ if (node.type === "text")
91
+ return (node.value.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? []).length;
92
+ return (node.children ?? []).reduce((sum, child) => sum + visit(child), 0);
93
+ };
94
+ return visit(tree);
95
+ }
96
+
97
+ /** Bridge selected Markdown paragraphs to retext for lint and scoring. */
98
+ async function retextProse(file, source, options = {}, inspect) {
99
+ const virtualFile = { value: maskMarkup(source), path: file, messages: [] };
100
+ const analysis = unified().use(retextEnglish);
101
+ if (options.rules !== "simplify" && options.rules !== "none") {
102
+ analysis.use(retextReadability, {
103
+ age: options.age ?? 21,
104
+ threshold: (options.threshold ?? 5) / 7,
105
+ minWords: options.minWords ?? 8,
106
+ });
107
+ }
108
+ if (options.rules === "simplify" || options.rules === "all") analysis.use(retextSimplify);
109
+ if (inspect) analysis.use(() => (tree) => inspect(tree));
110
+ analysis.use(retextStringify);
111
+ await unified().use(remarkRetext, analysis).run(proseTree(source), virtualFile);
112
+ return virtualFile.messages;
113
+ }
114
+
115
+ /** Source-aligned findings for one Markdown note. */
116
+ export async function analyzeProse(file, source, options = {}) {
117
+ const messages = await retextProse(file, source, options);
118
+ return messages.map((message) => {
56
119
  const position = message.place?.start;
57
120
  const sentence = message.ancestors?.find((node) => node.type === "SentenceNode");
58
121
  const span = sentence?.position;
@@ -84,3 +147,118 @@ export async function lintProse(root, options, skipDirectories = []) {
84
147
  }
85
148
  return findings;
86
149
  }
150
+
151
+ /** Concatenate the text leaves in a retext word. */
152
+ function wordText(node) {
153
+ return node.value ?? (node.children ?? []).map(wordText).join("");
154
+ }
155
+
156
+ /** Count the words and syllables in one retext sentence. */
157
+ function sentenceCounts(sentence) {
158
+ const words = [];
159
+ const visit = (node) => {
160
+ if (node.type === "WordNode") words.push(wordText(node));
161
+ else for (const child of node.children ?? []) visit(child);
162
+ };
163
+ visit(sentence);
164
+ return {
165
+ words: words.length,
166
+ syllables: words.reduce((sum, word) => sum + syllable(word), 0),
167
+ unfamiliar: words.filter((word) => !FAMILIAR_WORDS.has(word.toLowerCase())).length,
168
+ nominalizations: words.filter((word) => NOMINALIZATION.test(word)).length,
169
+ };
170
+ }
171
+
172
+ /** Score the complete prose paragraphs in one note. */
173
+ export async function scoreProse(file, source, { minWords = 80, bands = {} } = {}) {
174
+ const sentences = [];
175
+ await retextProse(file, source, { rules: "none" }, (tree) => {
176
+ const visit = (node) => {
177
+ if (node.type === "SentenceNode") sentences.push(sentenceCounts(node));
178
+ else for (const child of node.children ?? []) visit(child);
179
+ };
180
+ visit(tree);
181
+ });
182
+ const totals = sentences.reduce(
183
+ (sum, sentence) => ({
184
+ words: sum.words + sentence.words,
185
+ syllables: sum.syllables + sentence.syllables,
186
+ unfamiliar: sum.unfamiliar + sentence.unfamiliar,
187
+ nominalizations: sum.nominalizations + sentence.nominalizations,
188
+ }),
189
+ { words: 0, syllables: 0, unfamiliar: 0, nominalizations: 0 },
190
+ );
191
+ const longestSentenceWords = Math.max(0, ...sentences.map((sentence) => sentence.words));
192
+ const count = sentences.filter((sentence) => sentence.words > 0).length;
193
+ const metrics =
194
+ count && totals.words ?
195
+ {
196
+ flesch: flesch({ sentence: count, word: totals.words, syllable: totals.syllables }),
197
+ syllablesPerWord: totals.syllables / totals.words,
198
+ meanSentenceWords: totals.words / count,
199
+ longestSentenceWords,
200
+ unfamiliarWordPercent: (100 * totals.unfamiliar) / totals.words,
201
+ nominalizationsPer1000Words: (1000 * totals.nominalizations) / totals.words,
202
+ }
203
+ : null;
204
+ const insufficient = totals.words < minWords;
205
+ const violations = [];
206
+ if (!insufficient) {
207
+ for (const [metric, band] of Object.entries(bands)) {
208
+ if (band.min !== undefined && metrics[metric] < band.min) {
209
+ violations.push(`${metric} below ${band.min}`);
210
+ }
211
+ if (band.max !== undefined && metrics[metric] > band.max) {
212
+ violations.push(`${metric} above ${band.max}`);
213
+ }
214
+ }
215
+ }
216
+ const bodyWords = bodyWordCount(source);
217
+ return {
218
+ file,
219
+ ...totals,
220
+ bodyWords,
221
+ coveragePercent: bodyWords ? (100 * totals.words) / bodyWords : 0,
222
+ sentences: count,
223
+ longestSentenceWords,
224
+ metrics,
225
+ insufficient,
226
+ violations,
227
+ };
228
+ }
229
+
230
+ /** Score a note or content tree and pool counts for its aggregate. */
231
+ export async function scoreProseTree(root, options, skipDirectories = []) {
232
+ const notes = [];
233
+ for (const file of proseFiles(root, skipDirectories)) {
234
+ notes.push(await scoreProse(file, fs.readFileSync(file, "utf8"), options));
235
+ }
236
+ const scored = notes.filter((note) => !note.insufficient);
237
+ const totals = scored.reduce(
238
+ (sum, note) => ({
239
+ words: sum.words + note.words,
240
+ syllables: sum.syllables + note.syllables,
241
+ sentences: sum.sentences + note.sentences,
242
+ unfamiliar: sum.unfamiliar + note.unfamiliar,
243
+ nominalizations: sum.nominalizations + note.nominalizations,
244
+ }),
245
+ { words: 0, syllables: 0, sentences: 0, unfamiliar: 0, nominalizations: 0 },
246
+ );
247
+ return {
248
+ notes,
249
+ summary: {
250
+ ...totals,
251
+ scored: scored.length,
252
+ insufficient: notes.length - scored.length,
253
+ outside: scored.filter((note) => note.violations.length).length,
254
+ flesch:
255
+ totals.words && totals.sentences ?
256
+ flesch({
257
+ sentence: totals.sentences,
258
+ word: totals.words,
259
+ syllable: totals.syllables,
260
+ })
261
+ : null,
262
+ },
263
+ };
264
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.17.0",
3
+ "version": "22.17.1",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -137,10 +137,12 @@
137
137
  "acorn": "^8.18.0",
138
138
  "archiver": "^8.0.0",
139
139
  "classic-level": "^3.0.0",
140
+ "dale-chall": "^2.0.1",
140
141
  "dotenv": "^18.0.0",
141
142
  "eslint": "^10.10.0",
142
143
  "eslint-plugin-yml": "^3.8.1",
143
144
  "fflate": "^0.8.3",
145
+ "flesch": "^2.0.1",
144
146
  "glob": "^13.0.6",
145
147
  "gray-matter": "^4.0.3",
146
148
  "handlebars": "^4.7.9",
@@ -153,6 +155,7 @@
153
155
  "pagefind": "^1.5.2",
154
156
  "prettier": "^3.9.6",
155
157
  "remark-frontmatter": "^5.0.0",
158
+ "remark-gfm": "^4.0.1",
156
159
  "remark-parse": "^11.0.0",
157
160
  "remark-retext": "^6.0.1",
158
161
  "retext-english": "^5.0.0",
@@ -162,6 +165,7 @@
162
165
  "sharp": "^0.35.4",
163
166
  "smol-toml": "^1.8.0",
164
167
  "ssh2-sftp-client": "^12.1.1",
168
+ "syllable": "^5.0.1",
165
169
  "typescript": "^6.0.3",
166
170
  "unidecode": "^1.1.0",
167
171
  "unified": "^11.0.5",
@@ -94,7 +94,8 @@ export const DERIVED_MANIFEST_KEYS: Readonly<Record<string, string>>;
94
94
  *
95
95
  * @typedef {object} PackageBuildConfig
96
96
  * @property {string} rootDir The repository root, from package-build.
97
- * @property {{age: number, threshold: number, minWords: number}} proseLint Optional prose analysis settings.
97
+ * @property {{age: number, threshold: number, minWords: number, rules: string}} proseLint Optional prose lint settings.
98
+ * @property {{minWords: number, bands: Record<string, {min?: number, max?: number}>}} proseScore Optional note score settings.
98
99
  * @property {string} packageKind `systems` or `modules`.
99
100
  * @property {string} packageId The Foundry package id.
100
101
  * @property {string} artifact Derived: `system` or `module`.
@@ -198,12 +199,23 @@ export type PackageBuildConfig = {
198
199
  */
199
200
  rootDir: string;
200
201
  /**
201
- * Optional prose analysis settings.
202
+ * Optional prose lint settings.
202
203
  */
203
204
  proseLint: {
204
205
  age: number;
205
206
  threshold: number;
206
207
  minWords: number;
208
+ rules: string;
209
+ };
210
+ /**
211
+ * Optional note score settings.
212
+ */
213
+ proseScore: {
214
+ minWords: number;
215
+ bands: Record<string, {
216
+ min?: number;
217
+ max?: number;
218
+ }>;
207
219
  };
208
220
  /**
209
221
  * `systems` or `modules`.
@@ -1,11 +1,7 @@
1
1
  /** Files in a content tree, excluding configured directory names. */
2
2
  export function proseFiles(root: any, skipDirectories?: any[]): any[];
3
3
  /** Source-aligned findings for one Markdown note. */
4
- export function analyzeProse(file: any, source: any, { age, threshold, minWords }?: {
5
- age?: number | undefined;
6
- threshold?: number | undefined;
7
- minWords?: number | undefined;
8
- }): Promise<{
4
+ export function analyzeProse(file: any, source: any, options?: {}): Promise<{
9
5
  file: any;
10
6
  line: any;
11
7
  column: any;
@@ -20,3 +16,13 @@ export function lintProse(root: any, options: any, skipDirectories?: any[]): Pro
20
16
  severity: string;
21
17
  message: string;
22
18
  }[]>;
19
+ /** Score the complete prose paragraphs in one note. */
20
+ export function scoreProse(file: any, source: any, { minWords, bands }?: {
21
+ minWords?: number | undefined;
22
+ bands?: {} | undefined;
23
+ }): Promise<any>;
24
+ /** Score a note or content tree and pool counts for its aggregate. */
25
+ export function scoreProseTree(root: any, options: any, skipDirectories?: any[]): Promise<{
26
+ notes: any[];
27
+ summary: any;
28
+ }>;