From d123b857179795b95f446032309ca4bf3189a566 Mon Sep 17 00:00:00 2001 From: Tim Pearson Date: Wed, 26 Aug 2026 22:20:50 -0400 Subject: [PATCH] fix: derive word_count from the content actually resolved (#10) BookStack only populates page.text for markdown-authored pages, so enhancePageResponse reported word_count: 0 for every WYSIWYG page even when it returned thousands of characters of content. Fall back to markdown, then to tag-stripped html. Splitting on /\s+/ rather than a single space also corrects the count for newline-separated text on markdown-authored pages, which previously read as one word. Documents the Export Content role permission the WYSIWYG markdown fallback needs; without it the fallback fails silently and the page reads as empty. --- README.md | 6 ++++++ src/bookstack-client.ts | 3 ++- src/util/word-count.test.ts | 38 +++++++++++++++++++++++++++++++++++++ src/util/word-count.ts | 23 ++++++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/util/word-count.test.ts create mode 100644 src/util/word-count.ts diff --git a/README.md b/README.md index bb1a571..34b1bdd 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,12 @@ Both templates support `id` autocompletion: as you type, the server searches Boo 4. In the **API Tokens** section, create a new token 5. Copy the Token ID and Token Secret +> **Also grant the role “Export Content”** if you read pages written in the +> WYSIWYG editor. BookStack returns an empty `markdown` body for those pages, and +> `get_page` recovers it from the server-side HTML→markdown export endpoint. Without +> the permission that fallback fails and the page reads as empty. The default Viewer +> role does not include it. + ## Security - Write operations are **disabled by default** diff --git a/src/bookstack-client.ts b/src/bookstack-client.ts index d34bfe2..a3d532d 100644 --- a/src/bookstack-client.ts +++ b/src/bookstack-client.ts @@ -1,6 +1,7 @@ import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosAdapter } from 'axios'; import https from 'https'; import { Semaphore } from './util/semaphore.js'; +import { countWords } from './util/word-count.js'; const MAX_RETRIES_429 = 5; @@ -402,7 +403,7 @@ export class BookStackClient { return { ...pageMeta, url, - word_count: page.text ? page.text.split(' ').length : 0, + word_count: countWords(page), content_format: format, content_total_chars: totalChars, content_offset: offset, diff --git a/src/util/word-count.test.ts b/src/util/word-count.test.ts new file mode 100644 index 0000000..1bc6f5c --- /dev/null +++ b/src/util/word-count.test.ts @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { countWords } from './word-count.js'; + +test('counts words in page.text when BookStack populates it', () => { + assert.equal(countWords({ text: 'one two three' }), 3); +}); + +test('splits on any whitespace, not just a single space', () => { + assert.equal(countWords({ text: 'one\ntwo\tthree four\r\nfive' }), 5); +}); + +test('ignores leading, trailing and repeated whitespace', () => { + assert.equal(countWords({ text: ' one two ' }), 2); +}); + +test('falls back to markdown when text is empty (WYSIWYG pages)', () => { + assert.equal(countWords({ text: '', markdown: '# Title\n\nBody text here' }), 5); +}); + +test('falls back to html when neither text nor markdown is present', () => { + assert.equal(countWords({ html: '

Hello there world

' }), 3); +}); + +test('does not count markup or script/style bodies as words', () => { + const html = '

only these three

'; + assert.equal(countWords({ html }), 3); +}); + +test('treats   as a separator', () => { + assert.equal(countWords({ html: '

one two

' }), 2); +}); + +test('returns 0 for a page with no content at all', () => { + assert.equal(countWords({}), 0); + assert.equal(countWords({ text: '', markdown: '', html: '' }), 0); + assert.equal(countWords({ text: null, markdown: null, html: null }), 0); +}); diff --git a/src/util/word-count.ts b/src/util/word-count.ts new file mode 100644 index 0000000..c057915 --- /dev/null +++ b/src/util/word-count.ts @@ -0,0 +1,23 @@ +// BookStack only populates page.text for markdown-authored pages. For pages +// written in the WYSIWYG editor it comes back empty, so a word count taken +// from page.text alone reports 0 for pages that plainly have content (#10). +// Fall back to whichever body the response actually carries. + +function stripHtml(html: string): string { + return html + .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' '); +} + +export function countWords(page: { + text?: string | null; + markdown?: string | null; + html?: string | null; +}): number { + const source = + page.text || page.markdown || (page.html ? stripHtml(page.html) : ''); + // Split on any whitespace, not a single space: a page whose lines are + // newline-separated counts as one word under /' '/. + return source.split(/\s+/).filter(Boolean).length; +}