diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index 81ac4e54..99149024 100644 --- a/packages/app-core/src/components/StatusBar.tsx +++ b/packages/app-core/src/components/StatusBar.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useStore } from '../store' import type { NoteContent, NoteMeta } from '@shared/ipc' import { backlinksForNote } from '../lib/wikilinks' +import { countWords } from '../lib/word-count' /** * Footer strip showing quick stats for the active note: backlinks, @@ -17,12 +18,7 @@ export function StatusBar({ note }: { note: NoteContent }): JSX.Element { const { words, characters, minutes } = useMemo(() => { const body = note.body - const stripped = body - .replace(/^---\n[\s\S]*?\n---\n/, '') - .replace(/```[\s\S]*?```/g, ' ') - .replace(/`[^`\n]*`/g, ' ') - const wordArr = stripped.trim().split(/\s+/).filter(Boolean) - const w = wordArr.length + const w = countWords(body) const c = body.length const m = Math.max(1, Math.round(w / 200)) return { words: w, characters: c, minutes: m } diff --git a/packages/app-core/src/lib/word-count.test.ts b/packages/app-core/src/lib/word-count.test.ts new file mode 100644 index 00000000..1a1392a3 --- /dev/null +++ b/packages/app-core/src/lib/word-count.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { countWords } from './word-count' + +describe('countWords', () => { + it('returns 0 for an empty string', () => { + expect(countWords('')).toBe(0) + }) + + it('returns 0 for whitespace-only input', () => { + expect(countWords(' \n\t ')).toBe(0) + }) + + it('counts words separated by whitespace', () => { + expect(countWords('hello world')).toBe(2) + }) + + it('treats consecutive whitespace as a single separator', () => { + expect(countWords(' hello\n\nworld\t! ')).toBe(3) + }) + + it('strips YAML frontmatter at the start of the document', () => { + const body = '---\ntitle: foo\ntags: [a, b]\n---\nhello world' + expect(countWords(body)).toBe(2) + }) + + it('handles CRLF line endings in frontmatter', () => { + const body = '---\r\ntitle: foo\r\n---\r\nhello world' + expect(countWords(body)).toBe(2) + }) + + it('does not strip --- horizontal rules in the body', () => { + const body = '# Heading\n\nfoo\n\n---\n\nbar' + expect(countWords(body)).toBe(5) + }) + + it('counts words inside fenced code blocks (issue #43)', () => { + const body = 'before\n\n```python\ndef hello():\n return "world"\n```\n\nafter' + expect(countWords(body)).toBe(8) + }) + + it('counts words inside inline code (issue #43)', () => { + expect(countWords('use the `console.log` function')).toBe(4) + }) +}) diff --git a/packages/app-core/src/lib/word-count.ts b/packages/app-core/src/lib/word-count.ts new file mode 100644 index 00000000..26b10cb6 --- /dev/null +++ b/packages/app-core/src/lib/word-count.ts @@ -0,0 +1,13 @@ +/** + * Count words in a markdown body the way Obsidian does: strip the + * YAML frontmatter, then treat every whitespace-separated token as + * a word. Code blocks and inline code stay counted — Obsidian + * counts the words inside them and an earlier implementation + * stripping them caused issue #43 (huge undercounts on code-heavy + * notes). + */ +export function countWords(body: string): number { + const stripped = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '') + const matches = stripped.match(/\S+/g) + return matches?.length ?? 0 +}