@heroiclands/package-build 22.16.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 +24 -0
- package/bin/content-commands.mjs +93 -14
- package/bin/package-build.mjs +14 -2
- package/config.mjs +54 -3
- package/docs/api.md +22 -22
- package/docs/authoring/dates-and-calendars.md +63 -11
- package/docs/authoring/links-and-markup.md +1 -1
- package/docs/commands.md +78 -40
- package/docs/configuration.md +49 -22
- package/docs/reference/format-details.md +107 -165
- package/docs/reference/note-types.md +11 -10
- package/engine/calendar-format.mjs +441 -0
- package/engine/calendar-human.mjs +65 -3
- package/engine/calendar-notes.mjs +277 -96
- package/engine/content-format.yaml +2 -1
- package/engine/date-conversion.mjs +4 -4
- package/engine/markdown-expressions.mjs +2 -1
- package/engine/note-dates.mjs +50 -22
- package/engine/readability-lint.mjs +196 -18
- package/engine/reckoning-markers.mjs +154 -256
- package/package.json +5 -1
- package/types/config.d.mts +14 -2
- package/types/engine/calendar-format.d.mts +33 -0
- package/types/engine/calendar-notes.d.mts +3 -3
- package/types/engine/date-conversion.d.mts +1 -1
- package/types/engine/note-dates.d.mts +2 -2
- package/types/engine/readability-lint.d.mts +11 -5
- package/types/engine/reckoning-markers.d.mts +13 -28
package/engine/note-dates.mjs
CHANGED
|
@@ -14,20 +14,27 @@
|
|
|
14
14
|
/**
|
|
15
15
|
* Parse authored note dates into records used by content checks and renderers.
|
|
16
16
|
*
|
|
17
|
-
* A neutral
|
|
18
|
-
* the world's year. A
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* A neutral date is `<year>[.<day>[:HHMMSS]]`, where `day` is an ordinal
|
|
18
|
+
* within the world's year. A bare year spans that year; a day spans that day;
|
|
19
|
+
* a clock time identifies a second. A named date uses the `datefrom` form;
|
|
20
|
+
* its calendar and era resolve the value on the neutral timeline.
|
|
21
|
+
* Either form accepts `~` for uncertainty beyond its stated interval.
|
|
22
|
+
* `unknown` is unordered.
|
|
21
23
|
*
|
|
22
24
|
* `text` keeps the authored spelling. `canonicalYear` and `sort` are set for
|
|
23
|
-
* neutral and resolved named dates.
|
|
24
|
-
*
|
|
25
|
+
* neutral and resolved named dates. A neutral year retains `precision: "year"`
|
|
26
|
+
* and has no `canonicalDay`; day one is used only to calculate its bounds.
|
|
25
27
|
*
|
|
26
28
|
* @module
|
|
27
29
|
*/
|
|
28
30
|
|
|
29
31
|
import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
|
|
30
32
|
import { addressedCalendarDate } from "./calendar-human.mjs";
|
|
33
|
+
import {
|
|
34
|
+
calendarFormatHasClock,
|
|
35
|
+
formatCalendarPattern,
|
|
36
|
+
selectCalendarFormat,
|
|
37
|
+
} from "./calendar-format.mjs";
|
|
31
38
|
import {
|
|
32
39
|
calendarStructure,
|
|
33
40
|
canonicalDateFromOffset,
|
|
@@ -188,10 +195,7 @@ export function parseNoteDate(value, options) {
|
|
|
188
195
|
};
|
|
189
196
|
|
|
190
197
|
const resolveEra = (parsed, reckoning, label) => {
|
|
191
|
-
if (
|
|
192
|
-
return refuse(
|
|
193
|
-
`${subject} counts backward from ${label}, but only the first era of a calendar may do so`,
|
|
194
|
-
);
|
|
198
|
+
if (parsed.year < 1) return refuse(`${subject} needs a positive year in ${label}`);
|
|
195
199
|
if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1)
|
|
196
200
|
return refuse(`${subject} needs the world's year length to resolve ${label}`);
|
|
197
201
|
const { months } = calendarStructure(reckoning.calendar);
|
|
@@ -207,7 +211,7 @@ export function parseNoteDate(value, options) {
|
|
|
207
211
|
);
|
|
208
212
|
const ordinal = dayOfYear(months, parsed.month ?? 1, parsed.day ?? 1);
|
|
209
213
|
const start = canonicalDayOffset(reckoning.epochYear, reckoning.epochDay, 1, daysPerYear);
|
|
210
|
-
const yearOffset =
|
|
214
|
+
const yearOffset = reckoning.beforeEra ? -parsed.year : parsed.year - 1;
|
|
211
215
|
const offset = start + yearOffset * daysPerYear + ordinal - 1;
|
|
212
216
|
if (!Number.isSafeInteger(offset))
|
|
213
217
|
return refuse(`${subject} lies outside the supported canonical day range`);
|
|
@@ -283,10 +287,10 @@ export function parseNoteDate(value, options) {
|
|
|
283
287
|
`${subject} writes year 0, and no reckoning has one — the years either ` +
|
|
284
288
|
`side of an epoch are -1 and 1`,
|
|
285
289
|
);
|
|
286
|
-
if (field && !/^-?\d
|
|
290
|
+
if (field && !/^-?\d+(?:\.\d+(?::\d{6})?)?$/.test(unmarked))
|
|
287
291
|
return refuse(
|
|
288
292
|
`${subject} is not a frontmatter date — write ` +
|
|
289
|
-
"`<year
|
|
293
|
+
"`<year>[.<day>[:HHMMSS]]` or `datefrom <calendar> <date>`",
|
|
290
294
|
);
|
|
291
295
|
if (unmarked.includes(".")) {
|
|
292
296
|
const canonical = parseCanonicalDate(unmarked, daysPerYear ?? Number.MAX_SAFE_INTEGER);
|
|
@@ -301,13 +305,13 @@ export function parseNoteDate(value, options) {
|
|
|
301
305
|
day: canonical.day,
|
|
302
306
|
approximate: approximateInput,
|
|
303
307
|
precision: "day",
|
|
304
|
-
canonicalYear: canonical.year,
|
|
308
|
+
canonicalYear: canonicalYear(canonical.year),
|
|
305
309
|
canonicalDay: canonical.day,
|
|
306
310
|
spanDays: 1,
|
|
307
311
|
...(canonical.seconds === undefined ? {} : { seconds: canonical.seconds }),
|
|
308
312
|
sort:
|
|
309
313
|
Number.isSafeInteger(daysPerYear) ?
|
|
310
|
-
canonical.year +
|
|
314
|
+
canonicalYear(canonical.year) +
|
|
311
315
|
(canonical.day - 1 + (canonical.seconds ?? 0) / 86400) / daysPerYear
|
|
312
316
|
: null,
|
|
313
317
|
},
|
|
@@ -348,7 +352,7 @@ export function parseNoteDate(value, options) {
|
|
|
348
352
|
}
|
|
349
353
|
return refuse(
|
|
350
354
|
`${subject} is not a date — write ` +
|
|
351
|
-
`\`<year
|
|
355
|
+
`\`<year>[.<day>[:HHMMSS]]\` or ` +
|
|
352
356
|
`\`datefrom <calendar> <date>\``,
|
|
353
357
|
);
|
|
354
358
|
}
|
|
@@ -515,17 +519,17 @@ export function calendarEras(reference, context) {
|
|
|
515
519
|
}
|
|
516
520
|
|
|
517
521
|
/** Print a resolved date using the era active in one addressed calendar. */
|
|
518
|
-
export function formatDateInCalendar(date, reference, context) {
|
|
522
|
+
export function formatDateInCalendar(date, reference, context, formatName) {
|
|
519
523
|
const eras = calendarEras(reference, context);
|
|
520
524
|
const chosen = eraCovering(date, eras, context?.daysPerYear);
|
|
521
525
|
const name = String(reference ?? "");
|
|
522
526
|
if (context?.eras?.get(name)?.era && chosen === null)
|
|
523
527
|
throw new RangeError(`date falls outside calendar era ${name}`);
|
|
524
|
-
return formatNoteDate(date, chosen, context?.daysPerYear);
|
|
528
|
+
return formatNoteDate(date, chosen, context?.daysPerYear, formatName);
|
|
525
529
|
}
|
|
526
530
|
|
|
527
531
|
/** Print a resolved date in one era, retaining its authored precision. */
|
|
528
|
-
export function formatNoteDate(date, era, daysPerYear) {
|
|
532
|
+
export function formatNoteDate(date, era, daysPerYear, formatName) {
|
|
529
533
|
if (!date?.known || !Number.isSafeInteger(date.canonicalYear)) return null;
|
|
530
534
|
if (!era) return { era: null, year: eraYear(date.canonicalYear), text: date.text, prose: null };
|
|
531
535
|
if (!Number.isSafeInteger(daysPerYear) || !Number.isSafeInteger(date.spanDays)) return null;
|
|
@@ -538,7 +542,8 @@ export function formatNoteDate(date, era, daysPerYear) {
|
|
|
538
542
|
canonicalDayOffset(target.epochYear, target.epochDay ?? 1, 1, daysPerYear);
|
|
539
543
|
const delta = start - epoch;
|
|
540
544
|
const cycle = Math.floor(delta / daysPerYear);
|
|
541
|
-
const year =
|
|
545
|
+
const year = target.beforeEra ? -cycle : cycle + 1;
|
|
546
|
+
if (year < 1) return null;
|
|
542
547
|
if (Math.floor((delta + date.spanDays - 1) / daysPerYear) !== cycle) return null;
|
|
543
548
|
|
|
544
549
|
let month = null;
|
|
@@ -565,14 +570,37 @@ export function formatNoteDate(date, era, daysPerYear) {
|
|
|
565
570
|
month === null ? null : calendarStructure(target.calendar).months?.[month - 1]?.name;
|
|
566
571
|
const eraName =
|
|
567
572
|
target.abbreviation || target.marker || target.name || target.era.split(".").at(-1);
|
|
568
|
-
|
|
573
|
+
let text = `${date.approximate ? "~" : ""}${day === null ? "" : `${day} `}${monthName ? `${monthName} ` : ""}${year} ${eraName}${clock}`;
|
|
574
|
+
if (target.calendar?.formats) {
|
|
575
|
+
const { pattern } = selectCalendarFormat(target.calendar, formatName);
|
|
576
|
+
const hasClock = calendarFormatHasClock(pattern);
|
|
577
|
+
const rendered = formatCalendarPattern(
|
|
578
|
+
pattern,
|
|
579
|
+
{
|
|
580
|
+
calendar: target.calendar,
|
|
581
|
+
era: target,
|
|
582
|
+
eraYear: year,
|
|
583
|
+
calendarYear: target.beforeEra ? -year : target.startYear + year - 1,
|
|
584
|
+
month,
|
|
585
|
+
day,
|
|
586
|
+
dayOfYear: (((delta % daysPerYear) + daysPerYear) % daysPerYear) + 1,
|
|
587
|
+
seconds: date.seconds,
|
|
588
|
+
canonicalOffset: start,
|
|
589
|
+
calendarEpochOffset: target.calendarEpochOffset,
|
|
590
|
+
},
|
|
591
|
+
date.precision,
|
|
592
|
+
date.seconds !== undefined,
|
|
593
|
+
);
|
|
594
|
+
text = `${date.approximate ? "~" : ""}${rendered}${date.seconds !== undefined && !hasClock ? clock : ""}`;
|
|
595
|
+
}
|
|
569
596
|
const digits = `${Math.abs(year)}${month === null ? "" : `/${month}`}${day === null ? "" : `/${day}${clock.replaceAll(":", "").replace(/^ /, ":")}`}`;
|
|
570
597
|
const label =
|
|
571
598
|
typeof target.label === "string" ?
|
|
572
599
|
target.label
|
|
573
600
|
: target.label?.[year < 0 ? "before" : "after"];
|
|
574
601
|
const prose =
|
|
575
|
-
|
|
602
|
+
target.calendar?.formats ? null
|
|
603
|
+
: typeof label === "string" && label.includes("{date}") ?
|
|
576
604
|
label.replace("{date}", `${date.approximate ? "~" : ""}${digits}`)
|
|
577
605
|
: null;
|
|
578
606
|
return { era, year, month, day, text, prose };
|
|
@@ -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
|
|
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
|
-
/**
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
}
|