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 
+ * - 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  here')).not.toMatch(/x\.com/);
+ expect(stripMarkdown('see  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