diff --git a/src/index.html b/src/index.html index 44513f4..0c21dcc 100644 --- a/src/index.html +++ b/src/index.html @@ -2847,6 +2847,10 @@ | Chars: 0 | + ~0 min read + | + Grade — + | Ln 1, Col 1 | UTF-8 diff --git a/src/renderer.js b/src/renderer.js index 5580b13..ee98788 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -5,6 +5,7 @@ const { ipcRenderer, webUtils } = require('electron'); const { AutosaveController } = require('./renderer/autosave-client'); +const writingStats = require('./utils/writing-stats'); // Renderer-side autosave controller. The TabManager calls into this when a // tab becomes dirty so the current buffer is periodically persisted under @@ -1203,17 +1204,25 @@ class TabManager { const tab = this.tabs.get(this.activeTabId); if (!tab) return; const content = tab.content; - const words = content.trim() - ? content - .trim() - .split(/\s+/) - .filter((word) => word.length > 0).length - : 0; + // Words/chars used the raw buffer before — reading-time + grade level + // need the markdown chrome (code fences, link URLs, image syntax) stripped + // out, which writing-stats.stripMarkdown() does. + const stats = writingStats.computeStats(content || ''); const chars = content.length; const wordEl = document.getElementById('word-count'); const charEl = document.getElementById('char-count'); - if (wordEl) wordEl.textContent = `Words: ${words}`; + const timeEl = document.getElementById('reading-time'); + const gradeEl = document.getElementById('grade-level'); + if (wordEl) wordEl.textContent = `Words: ${stats.wordCount}`; if (charEl) charEl.textContent = `Chars: ${chars}`; + if (timeEl) { + const mins = stats.readingTimeMinutes; + timeEl.textContent = mins < 1 ? '<1 min read' : `~${Math.ceil(mins)} min read`; + } + if (gradeEl) { + gradeEl.textContent = + stats.fleschKincaidGrade === null ? 'Grade —' : `Grade ${stats.fleschKincaidGrade}`; + } } updateCursorPosition(view) { if (!view) return; diff --git a/src/utils/writing-stats.js b/src/utils/writing-stats.js new file mode 100644 index 0000000..1200e61 --- /dev/null +++ b/src/utils/writing-stats.js @@ -0,0 +1,147 @@ +/** + * Writing stats — word count, reading time, Flesch-Kincaid grade level. + * + * Pure, dependency-free module. The renderer pulls this into a status-bar + * widget that updates on every keystroke. Counts exclude code fences and + * link/image URLs (the prose a reader actually consumes), which is what + * serious writing tools (Hemingway, iA Writer) do. + * + * Flesch-Kincaid grade level is the standard US-grade readability score: + grade = 0.39 * (words/sentences) + 11.8 * (syllables/words) - 15.59 + * Used widely in education and journalism to gauge reading difficulty. + * + * @module writing-stats + */ + +/** + * Strip markdown chrome so we count the prose a reader actually sees. + * - fenced code blocks ```…``` + * - inline code `…` + * - image markup ![alt](url) + * - link markup [text](url) → keep "text" only + * - emphasis markers *, _, **, __ + * - heading hashes at line start + * - blockquote markers + * - list bullets at line start (- * +) + * - HTML comments + */ +function stripMarkdown(content) { + if (typeof content !== 'string') return ''; + let s = content; + // Fenced code blocks: drop the whole block including the fences + s = s.replace(/```[\s\S]*?```/g, ' '); + s = s.replace(/~~~[\s\S]*?~~~/g, ' '); + // Inline code + s = s.replace(/`[^`\n]*`/g, ' '); + // Images: drop entirely (the URL is not prose) + s = s.replace(/!\[[^\]]*\]\([^)]*\)/g, ' '); + // Links: keep the text, drop the URL part + s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); + // Wikilinks: keep the visible label + s = s.replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, a, _b, c) => (c || a)); + // Headings at line start + s = s.replace(/^\s{0,3}#{1,6}\s+/gm, ''); + // Blockquote markers + s = s.replace(/^\s{0,3}>\s?/gm, ''); + // List bullets / numbers at line start + s = s.replace(/^\s{0,3}([-*+]|\d+\.)\s+/gm, ''); + // Emphasis markers + s = s.replace(/(\*\*|__)(.*?)\1/g, '$2'); + s = s.replace(/(\*|_)(.*?)\1/g, '$2'); + // Strikethrough + s = s.replace(/~~(.*?)~~/g, '$1'); + // HTML comments + s = s.replace(//g, ' '); + // Horizontal rules + s = s.replace(/^\s*([-*_])\s*\1\s*\1[\s\S]*?$/gm, ' '); + return s; +} + +/** Split prose into sentences. Robust enough for ASCII prose + common punctuation. */ +function splitSentences(prose) { + if (!prose) return []; + // Split on . ! ? followed by whitespace or end. Keep abbreviations rough. + const parts = prose.split(/(?<=[.!?])\s+/); + return parts + .map((s) => s.trim()) + .filter((s) => /[A-Za-z0-9À-ɏ]/.test(s) && s.length >= 2); +} + +/** + * Approximate syllable count for an English word. The standard regex-based + * heuristic (Flesch's original) — accurate to within ±1 syllable for ~80% + * of common English words, which is good enough for grade-level scoring. + */ +function countSyllablesInWord(word) { + if (!word) return 0; + const w = word.toLowerCase().replace(/[^a-z]/g, ''); + if (w.length === 0) return 0; + if (w.length <= 3) return 1; + // Drop trailing silent e, es, ed + let stripped = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, ''); + stripped = stripped.replace(/^y/, ''); + const matches = stripped.match(/[aeiouy]{1,2}/g); + return matches ? matches.length : 1; +} + +function countSyllables(prose) { + const words = (prose.match(/\b[A-Za-z'À-ɏ]+\b/g) || []); + let total = 0; + for (const w of words) total += countSyllablesInWord(w); + return total; +} + +function countWords(prose) { + if (!prose) return 0; + const matches = prose.match(/\b[\w'À-ɏ]+\b/g); + return matches ? matches.length : 0; +} + +/** + * Compute the full writing-stats report for a markdown document. + * + * @param {string} content raw markdown + * @param {object} [opts] + * @param {number} [opts.wordsPerMinute=220] average reading speed for English prose + * @returns {{ + * wordCount:number, + * sentenceCount:number, + * syllableCount:number, + * readingTimeMinutes:number, + * fleschKincaidGrade:number|null, + * charCount:number + * }} + */ +function computeStats(content, opts = {}) { + const prose = stripMarkdown(content); + const words = countWords(prose); + const sentences = splitSentences(prose).length; + const syllables = countSyllables(prose); + const wpm = typeof opts.wordsPerMinute === 'number' && opts.wordsPerMinute > 0 ? opts.wordsPerMinute : 220; + const readingTimeMinutes = words > 0 ? words / wpm : 0; + + let grade = null; + if (sentences > 0 && words > 0) { + grade = 0.39 * (words / sentences) + 11.8 * (syllables / words) - 15.59; + // Round to one decimal for status-bar display; round-trips cleanly. + grade = Math.round(grade * 10) / 10; + } + + return { + wordCount: words, + sentenceCount: sentences, + syllableCount: syllables, + readingTimeMinutes, + fleschKincaidGrade: grade, + charCount: prose.replace(/\s/g, '').length, + }; +} + +module.exports = { + stripMarkdown, + splitSentences, + countSyllablesInWord, + countSyllables, + countWords, + computeStats, +}; \ No newline at end of file diff --git a/tests/writing-stats.test.js b/tests/writing-stats.test.js new file mode 100644 index 0000000..4b23409 --- /dev/null +++ b/tests/writing-stats.test.js @@ -0,0 +1,180 @@ +/** + * @jest-environment node + * + * writing-stats tests — pure module, no IO. + */ +const { + stripMarkdown, + splitSentences, + countSyllablesInWord, + countSyllables, + countWords, + computeStats, +} = require('../src/utils/writing-stats'); + +describe('stripMarkdown', () => { + test('removes fenced code blocks', () => { + expect(stripMarkdown('hello\n```js\nfoo();\n```\nworld')).toMatch(/hello/); + expect(stripMarkdown('hello\n```js\nfoo();\n```\nworld')).not.toMatch(/foo/); + }); + + test('removes inline code', () => { + expect(stripMarkdown('use the `npm install` command')).not.toMatch(/npm install/); + expect(stripMarkdown('use the `npm install` command')).toMatch(/use the/); + }); + + test('strips image syntax entirely (URLs are not prose)', () => { + expect(stripMarkdown('see ![diagram](https://x.com/a.png) here')).not.toMatch(/x\.com/); + expect(stripMarkdown('see ![diagram](https://x.com/a.png) here')).toMatch(/see/); + }); + + test('keeps link text but drops URLs', () => { + expect(stripMarkdown('read [the docs](https://docs.example.com) now')).toMatch(/the docs/); + expect(stripMarkdown('read [the docs](https://docs.example.com) now')).not.toMatch(/docs\.example/); + }); + + test('keeps wikilink visible labels', () => { + expect(stripMarkdown('see [[Project X|the project]] page')).toMatch(/the project/); + }); + + test('strips heading hashes, blockquote markers, list bullets', () => { + expect(stripMarkdown('# Title\n\n> A quote\n\n- a bullet\n\n1. numbered')).toMatch(/Title/); + expect(stripMarkdown('# Title')).not.toMatch(/^#/); + expect(stripMarkdown('> quoted')).not.toMatch(/^>/); + }); + + test('strips emphasis markers but keeps the inner text', () => { + expect(stripMarkdown('this is **very** _important_')).toMatch(/this is very important/); + expect(stripMarkdown('this is **very** _important_')).not.toMatch(/[_*]/); + }); + + test('handles empty / non-string input', () => { + expect(stripMarkdown('')).toBe(''); + expect(stripMarkdown(null)).toBe(''); + expect(stripMarkdown(undefined)).toBe(''); + expect(stripMarkdown(42)).toBe(''); + }); +}); + +describe('splitSentences', () => { + test('splits on . ! ?', () => { + expect(splitSentences('Hello. World! Are you there?')).toEqual([ + 'Hello.', + 'World!', + 'Are you there?', + ]); + }); + + test('returns [] for empty / non-prose input', () => { + expect(splitSentences('')).toEqual([]); + expect(splitSentences(null)).toEqual([]); + expect(splitSentences(' ')).toEqual([]); + expect(splitSentences('---')).toEqual([]); + }); + + test('drops fragments without letter content', () => { + expect(splitSentences('Real sentence. !!! Another one.')).toEqual([ + 'Real sentence.', + 'Another one.', + ]); + }); +}); + +describe('countSyllablesInWord', () => { + test('counts short words as 1 syllable', () => { + expect(countSyllablesInWord('a')).toBe(1); + expect(countSyllablesInWord('cat')).toBe(1); + }); + + test('counts common English words correctly', () => { + expect(countSyllablesInWord('hello')).toBe(2); + expect(countSyllablesInWord('world')).toBe(1); + expect(countSyllablesInWord('beautiful')).toBeGreaterThanOrEqual(2); + expect(countSyllablesInWord('reading')).toBeGreaterThanOrEqual(2); + }); + + test('handles words ending in silent e', () => { + expect(countSyllablesInWord('make')).toBe(1); + expect(countSyllablesInWord('made')).toBe(1); + }); + + test('returns 0 for non-word / empty input', () => { + expect(countSyllablesInWord('')).toBe(0); + expect(countSyllablesInWord(' ')).toBe(0); + expect(countSyllablesInWord(null)).toBe(0); + }); +}); + +describe('countSyllables / countWords', () => { + test('counts syllables and words for a known sentence', () => { + const prose = 'The quick brown fox jumps over the lazy dog'; + expect(countWords(prose)).toBe(9); + // The dog has ~1 syll; "quick" 1; "brown" 1; "jumps" 1; "over" 2; "lazy" 2; + // "the" 1; "fox" 1. Total 11 (within ±2 of any heuristic). + expect(countSyllables(prose)).toBeGreaterThanOrEqual(9); + expect(countSyllables(prose)).toBeLessThanOrEqual(13); + }); +}); + +describe('computeStats', () => { + test('returns zeros for an empty document', () => { + const s = computeStats(''); + expect(s.wordCount).toBe(0); + expect(s.sentenceCount).toBe(0); + expect(s.readingTimeMinutes).toBe(0); + expect(s.fleschKincaidGrade).toBeNull(); + }); + + test('handles non-string input safely', () => { + const s = computeStats(null); + expect(s.wordCount).toBe(0); + }); + + test('computes word count, reading time, grade for real prose', () => { + const md = `# Title + +The quick brown fox jumps over the lazy dog. This sentence has eight words total. + +\`\`\`js +// not counted +const x = 1; +\`\`\` + +A second paragraph with [a link](https://x.com) and **bold** words.`; + const s = computeStats(md); + expect(s.wordCount).toBeGreaterThan(15); + // Code block is excluded + expect(s.wordCount).toBeLessThan(30); + // Reading time at 220 wpm: 24 words ≈ 0.11 min + expect(s.readingTimeMinutes).toBeGreaterThan(0); + expect(s.readingTimeMinutes).toBeLessThan(1); + // Grade level is a number for prose with at least one sentence + expect(typeof s.fleschKincaidGrade).toBe('number'); + expect(s.sentenceCount).toBeGreaterThanOrEqual(2); + }); + + test('reading time scales with word count', () => { + const one = '# Title\n\nThe quick brown fox.'; + const two = '# Title\n\n' + 'The quick brown fox. '.repeat(10); + expect(computeStats(two).readingTimeMinutes).toBeGreaterThan( + computeStats(one).readingTimeMinutes + ); + }); + + test('honors a custom wordsPerMinute', () => { + const md = 'word '.repeat(220); + const s = computeStats(md, { wordsPerMinute: 100 }); + expect(s.readingTimeMinutes).toBeGreaterThan(2); + }); + + test('falls back to default wpm when given an invalid value', () => { + const s1 = computeStats('word '.repeat(220)); + const s2 = computeStats('word '.repeat(220), { wordsPerMinute: 0 }); + expect(s2.readingTimeMinutes).toBe(s1.readingTimeMinutes); + }); + + test('charCount excludes whitespace', () => { + const s = computeStats(' hello world '); + expect(s.charCount).toBe(10); // "helloworld" + }); +}); \ No newline at end of file