Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
3 changes: 2 additions & 1 deletion src/bookstack-client.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions src/util/word-count.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>Hello <strong>there</strong> world</p>' }), 3);
});

test('does not count markup or script/style bodies as words', () => {
const html = '<style>.a{color:red}</style><script>var x = 1;</script><p>only these three</p>';
assert.equal(countWords({ html }), 3);
});

test('treats &nbsp; as a separator', () => {
assert.equal(countWords({ html: '<p>one&nbsp;two</p>' }), 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);
});
23 changes: 23 additions & 0 deletions src/util/word-count.ts
Original file line number Diff line number Diff line change
@@ -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(/&nbsp;/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;
}
Loading